tao-test/app/taoQtiTest/views/js/testRunner/testReview.js

891 lines
31 KiB
JavaScript

/**
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU General Public License
* as published by the Free Software Foundation; under version 2
* of the License (non-upgradable).
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program; if not, write to the Free Software
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
* Copyright (c) 2015 (original work) Open Assessment Technologies SA ;
*/
/**
* @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com>
*/
define([
'jquery',
'lodash',
'i18n',
'tpl!taoQtiTest/testRunner/tpl/navigator',
'tpl!taoQtiTest/testRunner/tpl/navigatorTree',
'util/capitalize'
], function ($, _, __, navigatorTpl, navigatorTreeTpl, capitalize) {
'use strict';
/**
* List of CSS classes
* @type {Object}
* @private
*/
var _cssCls = {
active : 'active',
collapsed : 'collapsed',
collapsible : 'collapsible',
hidden : 'hidden',
disabled : 'disabled',
flagged : 'flagged',
answered : 'answered',
viewed : 'viewed',
unseen : 'unseen',
icon : 'qti-navigator-icon',
scope : {
test : 'scope-test',
testPart : 'scope-test-part',
testSection : 'scope-test-section'
}
};
/**
* List of common CSS selectors
* @type {Object}
* @private
*/
var _selectors = {
component : '.qti-navigator',
filterBar : '.qti-navigator-filters',
tree : '.qti-navigator-tree',
collapseHandle : '.qti-navigator-collapsible',
linearState : '.qti-navigator-linear',
infoAnswered : '.qti-navigator-answered .qti-navigator-counter',
infoViewed : '.qti-navigator-viewed .qti-navigator-counter',
infoUnanswered : '.qti-navigator-unanswered .qti-navigator-counter',
infoFlagged : '.qti-navigator-flagged .qti-navigator-counter',
infoPanel : '.qti-navigator-info',
infoPanelLabels : '.qti-navigator-info > .qti-navigator-label',
parts : '.qti-navigator-part',
partLabels : '.qti-navigator-part > .qti-navigator-label',
sections : '.qti-navigator-section',
sectionLabels : '.qti-navigator-section > .qti-navigator-label',
items : '.qti-navigator-item',
itemLabels : '.qti-navigator-item > .qti-navigator-label',
itemIcons : '.qti-navigator-item > .qti-navigator-icon',
icons : '.qti-navigator-icon',
linearStart : '.qti-navigator-linear-part button',
counters : '.qti-navigator-counter',
actives : '.active',
collapsible : '.collapsible',
collapsiblePanels : '.collapsible-panel',
unseen : '.unseen',
answered : '.answered',
flagged : '.flagged',
notFlagged : ':not(.flagged)',
notAnswered : ':not(.answered)',
hidden : '.hidden'
};
/**
* Maps the filter mode to filter criteria.
* Each filter criteria is a CSS selector used to find and mask the items to be discarded by the filter.
* @type {Object}
* @private
*/
var _filterMap = {
all : "",
unanswered : _selectors.answered,
flagged : _selectors.notFlagged,
answered : _selectors.notAnswered,
filtered : _selectors.hidden
};
/**
* Maps of config options translated from the context object to the local options
* @type {Object}
* @private
*/
var _optionsMap = {
'reviewScope' : 'reviewScope',
'reviewPreventsUnseen' : 'preventsUnseen',
'canCollapse' : 'canCollapse'
};
/**
* Maps the handled review scopes
* @type {Object}
* @private
*/
var _reviewScopes = {
test : 'test',
testPart : 'testPart',
testSection : 'testSection'
};
/**
* Provides a test review manager
* @type {Object}
*/
var testReview = {
/**
* Initializes the component
* @param {String|jQuery|HTMLElement} element The element on which install the component
* @param {Object} [options] A list of extra options
* @param {String} [options.region] The region on which put the component: left or right
* @param {String} [options.reviewScope] Limit the review screen to a particular scope:
* the whole test, the current test part or the current test section)
* @param {Boolean} [options.preventsUnseen] Prevents the test taker to access unseen items
* @returns {testReview}
*/
init: function init(element, options) {
var initOptions = _.isObject(options) && options || {};
var putOnRight = 'right' === initOptions.region;
var insertMethod = putOnRight ? 'append' : 'prepend';
this.options = initOptions;
this.disabled = false;
this.hidden = !!initOptions.hidden;
this.currentFilter = 'all';
// clean the DOM if the init method is called after initialisation
if (this.$component) {
this.$component.remove();
}
// build the component structure and inject it into the DOM
this.$container = $(element);
insertMethod = this.$container[insertMethod];
if (insertMethod) {
insertMethod.call(this.$container, navigatorTpl({
region: putOnRight ? 'right' : 'left',
hidden: this.hidden
}));
} else {
throw new Error("Unable to inject the component structure into the DOM");
}
// install the component behaviour
this._loadDOM();
this._initEvents();
this._updateDisplayOptions();
return this;
},
/**
* Links the component to the underlying DOM elements
* @private
*/
_loadDOM: function() {
this.$component = this.$container.find(_selectors.component);
// access to info panel displaying counters
this.$infoAnswered = this.$component.find(_selectors.infoAnswered);
this.$infoViewed = this.$component.find(_selectors.infoViewed);
this.$infoUnanswered = this.$component.find(_selectors.infoUnanswered);
this.$infoFlagged = this.$component.find(_selectors.infoFlagged);
// access to filter switches
this.$filterBar = this.$component.find(_selectors.filterBar);
this.$filters = this.$filterBar.find('li');
// access to the tree of parts/sections/items
this.$tree = this.$component.find(_selectors.tree);
// access to the panel displayed when a linear part is reached
this.$linearState = this.$component.find(_selectors.linearState);
},
/**
* Installs the event handlers on the underlying DOM elements
* @private
*/
_initEvents: function() {
var self = this;
// click on the collapse handle: collapse/expand the review panel
this.$component.on('click' + _selectors.component, _selectors.collapseHandle, function() {
if (self.disabled) {
return;
}
self.$component.toggleClass(_cssCls.collapsed);
if (self.$component.hasClass(_cssCls.collapsed)) {
self._openSelected();
}
});
// click on the info panel title: toggle the related panel
this.$component.on('click' + _selectors.component, _selectors.infoPanelLabels, function() {
if (self.disabled) {
return;
}
var $panel = $(this).closest(_selectors.infoPanel);
self._togglePanel($panel, _selectors.infoPanel);
});
// click on a part title: toggle the related panel
this.$tree.on('click' + _selectors.component, _selectors.partLabels, function() {
if (self.disabled) {
return;
}
var $panel = $(this).closest(_selectors.parts);
var open = self._togglePanel($panel, _selectors.parts);
if (open) {
if ($panel.hasClass(_cssCls.active)) {
self._openSelected();
} else {
self._openOnly($panel.find(_selectors.sections).first(), $panel);
}
}
});
// click on a section title: toggle the related panel
this.$tree.on('click' + _selectors.component, _selectors.sectionLabels, function() {
if (self.disabled) {
return;
}
var $panel = $(this).closest(_selectors.sections);
self._togglePanel($panel, _selectors.sections);
});
// click on an item: jump to the position
this.$tree.on('click' + _selectors.component, _selectors.itemLabels, function(event) {
if (self.disabled) {
return;
}
var $item = $(this).closest(_selectors.items);
var $target;
if (!$item.hasClass(_cssCls.disabled)) {
$target = $(event.target);
if ($target.is(_selectors.icons) && !self.$component.hasClass(_cssCls.collapsed)) {
if (!$item.hasClass(_cssCls.unseen)) {
self._mark($item);
}
} else {
self._select($item);
self._jump($item);
}
}
});
// click on the start button inside a linear part: jump to the position
this.$tree.on('click' + _selectors.component, _selectors.linearStart, function() {
if (self.disabled) {
return;
}
var $btn = $(this);
if (!$btn.hasClass(_cssCls.disabled)) {
$btn.addClass(_cssCls.disabled);
self._jump($btn);
}
});
// click on a filter button
this.$filterBar.on('click' + _selectors.component, 'li', function() {
if (self.disabled) {
return;
}
var $btn = $(this);
var mode = $btn.data('mode');
self.$filters.removeClass(_cssCls.active);
self.$component.removeClass(_cssCls.collapsed);
$btn.addClass(_cssCls.active);
self._filter(mode);
});
},
/**
* Filters the items by a criteria
* @param {String} criteria
* @private
*/
_filter: function(criteria) {
var $items = this.$tree.find(_selectors.items).removeClass(_cssCls.hidden);
var filter = _filterMap[criteria];
if (filter) {
$items.filter(filter).addClass(_cssCls.hidden);
}
this._updateSectionCounters(!!filter);
this.currentFilter = criteria;
},
/**
* Selects an item
* @param {String|jQuery} position The item's position
* @param {Boolean} [open] Forces the tree to be opened on the selected item
* @returns {jQuery} Returns the selected item
* @private
*/
_select: function(position, open) {
// find the item to select and extract its hierarchy
var selected = position && position.jquery ? position : this.$tree.find('[data-position=' + position + ']');
var hierarchy = selected.parentsUntil(this.$tree);
// collapse the full tree and open only the hierarchy of the selected item
if (open) {
this._openOnly(hierarchy);
}
// select the item
this.$tree.find(_selectors.actives).removeClass(_cssCls.active);
hierarchy.add(selected).addClass(_cssCls.active);
return selected;
},
/**
* Opens the tree on the selected item only
* @returns {jQuery} Returns the selected item
* @private
*/
_openSelected: function() {
// find the selected item and extract its hierarchy
var selected = this.$tree.find(_selectors.items + _selectors.actives);
var hierarchy = selected.parentsUntil(this.$tree);
// collapse the full tree and open only the hierarchy of the selected item
this._openOnly(hierarchy);
return selected;
},
/**
* Collapses the full tree and opens only the provided branch
* @param {jQuery} opened The element to be opened
* @param {jQuery} [root] The root element from which collapse the panels
* @private
*/
_openOnly: function(opened, root) {
(root || this.$tree).find(_selectors.collapsible).addClass(_cssCls.collapsed);
opened.removeClass(_cssCls.collapsed);
},
/**
* Toggles a panel
* @param {jQuery} panel The panel to toggle
* @param {String} [collapseSelector] Selector of panels to collapse
* @returns {Boolean} Returns `true` if the panel just expanded now
*/
_togglePanel: function(panel, collapseSelector) {
var collapsed = panel.hasClass(_cssCls.collapsed);
if (collapseSelector) {
this.$tree.find(collapseSelector).addClass(_cssCls.collapsed);
}
if (collapsed) {
panel.removeClass(_cssCls.collapsed);
} else {
panel.addClass(_cssCls.collapsed);
}
return collapsed;
},
/**
* Sets the icon of a particular item
* @param {jQuery} $item
* @param {String} icon
* @private
*/
_setItemIcon: function($item, icon) {
$item.find(_selectors.icons).attr('class', _cssCls.icon + ' icon-' + icon);
},
/**
* Sets the icon of a particular item according to its state
* @param {jQuery} $item
* @private
*/
_adjustItemIcon: function($item) {
var icon = null;
var defaultIcon = _cssCls.unseen;
var iconCls = [
_cssCls.flagged,
_cssCls.answered,
_cssCls.viewed
];
_.forEach(iconCls, function(cls) {
if ($item.hasClass(cls)) {
icon = cls;
return false;
}
});
this._setItemIcon($item, icon || defaultIcon);
},
/**
* Toggle the marked state of an item
* @param {jQuery} $item
* @param {Boolean} [flag]
* @private
*/
_toggleFlag: function($item, flag) {
$item.toggleClass(_cssCls.flagged, flag);
this._adjustItemIcon($item);
},
/**
* Marks an item for later review
* @param {jQuery} $item
* @private
*/
_mark: function($item) {
var itemId = $item.data('id');
var itemPosition = $item.data('position');
var flag = !$item.hasClass(_cssCls.flagged);
this._toggleFlag($item);
/**
* A storage of the flag is required
* @event testReview#mark
* @param {Boolean} flag - Tells whether the item is marked for review or not
* @param {Number} position - The item position on which jump
* @param {String} itemId - The identifier of the target item
* @param {testReview} testReview - The client testReview component
*/
this.trigger('mark', [flag, itemPosition, itemId]);
},
/**
* Jumps to an item
* @param {jQuery} $item
* @private
*/
_jump: function($item) {
var itemId = $item.data('id');
var itemPosition = $item.data('position');
/**
* A jump to a particular item is required
* @event testReview#jump
* @param {Number} position - The item position on which jump
* @param {String} itemId - The identifier of the target item
* @param {testReview} testReview - The client testReview component
*/
this.trigger('jump', [itemPosition, itemId]);
},
/**
* Updates the sections related items counters
* @param {Boolean} filtered
*/
_updateSectionCounters: function(filtered) {
var self = this;
var filter = _filterMap[filtered ? 'filtered' : 'answered'];
this.$tree.find(_selectors.sections).each(function() {
var $section = $(this);
var $items = $section.find(_selectors.items);
var $filtered = $items.filter(filter);
var total = $items.length;
var nb = total - $filtered.length;
self._writeCount($section.find(_selectors.counters), nb, total);
});
},
/**
* Updates the display according to options
* @private
*/
_updateDisplayOptions: function() {
var reviewScope = _reviewScopes[this.options.reviewScope] || 'test';
var scopeClass = _cssCls.scope[reviewScope];
var $root = this.$component;
_.forEach(_cssCls.scope, function(cls) {
$root.removeClass(cls);
});
if (scopeClass) {
$root.addClass(scopeClass);
}
$root.toggleClass(_cssCls.collapsible, this.options.canCollapse);
},
/**
* Updates the local options from the provided context
* @param {Object} testContext The progression context
* @private
*/
_updateOptions: function(testContext) {
var options = this.options;
_.forEach(_optionsMap, function(optionKey, contextKey) {
if (undefined !== testContext[contextKey]) {
options[optionKey] = testContext[contextKey];
}
});
},
/**
* Updates the info panel
*/
_updateInfos: function() {
var progression = this.progression,
unanswered = Number(progression.total) - Number(progression.answered);
// update the info panel
this._writeCount(this.$infoAnswered, progression.answered, progression.total);
this._writeCount(this.$infoUnanswered, unanswered, progression.total);
this._writeCount(this.$infoViewed, progression.viewed, progression.total);
this._writeCount(this.$infoFlagged, progression.flagged, progression.total);
},
/**
* Updates a counter
* @param {jQuery} $place
* @param {Number} count
* @param {Number} total
* @private
*/
_writeCount: function($place, count, total) {
$place.text(count + '/' + total);
},
/**
* Gets the progression stats for the whole test
* @param {Object} testContext The progression context
* @returns {{total: (Number), answered: (Number), viewed: (Number), flagged: (Number)}}
* @private
*/
_getProgressionOfTest: function(testContext) {
return {
total : testContext.numberItems || 0,
answered : testContext.numberCompleted || 0,
viewed : testContext.numberPresented || 0,
flagged : testContext.numberFlagged || 0
};
},
/**
* Gets the progression stats for the current test part
* @param {Object} testContext The progression context
* @returns {{total: (Number), answered: (Number), viewed: (Number), flagged: (Number)}}
* @private
*/
_getProgressionOfTestPart: function(testContext) {
return {
total : testContext.numberItemsPart || 0,
answered : testContext.numberCompletedPart || 0,
viewed : testContext.numberPresentedPart || 0,
flagged : testContext.numberFlaggedPart || 0
};
},
/**
* Gets the progression stats for the current test section
* @param {Object} testContext The progression context
* @returns {{total: (Number), answered: (Number), viewed: (Number), flagged: (Number)}}
* @private
*/
_getProgressionOfTestSection: function(testContext) {
return {
total : testContext.numberItemsSection || 0,
answered : testContext.numberCompletedSection || 0,
viewed : testContext.numberPresentedSection || 0,
flagged : testContext.numberFlaggedSection || 0
};
},
/**
* Updates the navigation tre
* @param {Object} testContext The progression context
*/
_updateTree: function(testContext) {
var navigatorMap = testContext.navigatorMap;
var reviewScope = this.options.reviewScope;
var reviewScopePart = reviewScope === 'testPart';
var reviewScopeSection = reviewScope === 'testSection';
var _partsFilter = function(part) {
if (reviewScopeSection && part.sections) {
part.sections = _.filter(part.sections, _partsFilter);
}
return part.active;
};
// rebuild the tree
if (navigatorMap) {
if (reviewScopePart || reviewScopeSection) {
// display only the current section
navigatorMap = _.filter(navigatorMap, _partsFilter);
}
this.$filterBar.show();
this.$linearState.hide();
this.$tree.html(navigatorTreeTpl({
parts: navigatorMap
}));
if (this.options.preventsUnseen) {
// disables all unseen items to prevent the test taker has access to.
this.$tree.find(_selectors.unseen).addClass(_cssCls.disabled);
}
} else {
this.$filterBar.hide();
this.$linearState.show();
this.$tree.empty();
}
// apply again the current filter
this._filter(this.$filters.filter(_selectors.actives).data('mode'));
},
/**
* Set the marked state of an item
* @param {Number|String|jQuery} position
* @param {Boolean} flag
*/
setItemFlag: function setItemFlag(position, flag) {
var $item = position && position.jquery ? position : this.$tree.find('[data-position=' + position + ']');
var progression = this.progression;
// update the item flag
this._toggleFlag($item, flag);
// update the info panel
progression.flagged = this.$tree.find(_selectors.flagged).length;
this._writeCount(this.$infoFlagged, progression.flagged, progression.total);
this._filter(this.currentFilter);
},
/**
* Update the number of flagged items in the test context
* @param {Object} testContext The test context
* @param {Number} position The position of the flagged item
* @param {Boolean} flag The flag state
*/
updateNumberFlagged: function(testContext, position, flag) {
var fields = ['numberFlagged'];
var currentPosition = testContext.itemPosition;
var currentFound = false, currentSection = null, currentPart = null;
var itemFound = false, itemSection = null, itemPart = null;
if (testContext.navigatorMap) {
// find the current item and the marked item inside the navigator map
// check if the marked item is in the current section
_.forEach(testContext.navigatorMap, function(part) {
_.forEach(part && part.sections, function(section) {
_.forEach(section && section.items, function(item) {
if (item) {
if (item.position === position) {
itemPart = part;
itemSection = section;
itemFound = true;
}
if (item.position === currentPosition) {
currentPart = part;
currentSection = section;
currentFound = true;
}
if (itemFound && currentFound) {
return false;
}
}
});
if (itemFound && currentFound) {
return false;
}
});
if (itemFound && currentFound) {
return false;
}
});
// select the context to update
if (itemFound && currentPart === itemPart) {
fields.push('numberFlaggedPart');
}
if (itemFound && currentSection === itemSection) {
fields.push('numberFlaggedSection');
}
} else {
// no navigator map, the current the marked item is in the current section
fields.push('numberFlaggedPart');
fields.push('numberFlaggedSection');
}
_.forEach(fields, function(field) {
if (field in testContext) {
testContext[field] += flag ? 1 : -1;
}
});
},
/**
* Get progression
* @param {Object} testContext The progression context
* @returns {object} progression
*/
getProgression: function getProgression(testContext) {
var reviewScope = _reviewScopes[this.options.reviewScope] || 'test',
progressInfoMethod = '_getProgressionOf' + capitalize(reviewScope),
getProgression = this[progressInfoMethod] || this._getProgressionOfTest,
progression = getProgression && getProgression(testContext) || {};
return progression;
},
/**
* Updates the review screen
* @param {Object} testContext The progression context
* @returns {testReview}
*/
update: function update(testContext) {
this.progression = this.getProgression(testContext);
this._updateOptions(testContext);
this._updateInfos(testContext);
this._updateTree(testContext);
this._updateDisplayOptions(testContext);
return this;
},
/**
* Disables the component
* @returns {testReview}
*/
disable: function disable() {
this.disabled = true;
this.$component.addClass(_cssCls.disabled);
return this;
},
/**
* Enables the component
* @returns {testReview}
*/
enable: function enable() {
this.disabled = false;
this.$component.removeClass(_cssCls.disabled);
return this;
},
/**
* Hides the component
* @returns {testReview}
*/
hide: function hide() {
this.disabled = true;
this.hidden = true;
this.$component.addClass(_cssCls.hidden);
return this;
},
/**
* Shows the component
* @returns {testReview}
*/
show: function show() {
this.disabled = false;
this.hidden = false;
this.$component.removeClass(_cssCls.hidden);
return this;
},
/**
* Toggles the display state of the component
* @param {Boolean} [show] External condition that's tells if the component must be shown or hidden
* @returns {testReview}
*/
toggle: function toggle(show) {
if (undefined === show) {
show = this.hidden;
}
if (show) {
this.show();
} else {
this.hide();
}
return this;
},
/**
* Install an event handler on the underlying DOM element
* @param {String} eventName
* @returns {testReview}
*/
on: function on(eventName) {
var dom = this.$component;
if (dom) {
dom.on.apply(dom, arguments);
}
return this;
},
/**
* Uninstall an event handler from the underlying DOM element
* @param {String} eventName
* @returns {testReview}
*/
off: function off(eventName) {
var dom = this.$component;
if (dom) {
dom.off.apply(dom, arguments);
}
return this;
},
/**
* Triggers an event on the underlying DOM element
* @param {String} eventName
* @param {Array|Object} extraParameters
* @returns {testReview}
*/
trigger : function trigger(eventName, extraParameters) {
var dom = this.$component;
if (undefined === extraParameters) {
extraParameters = [];
}
if (!_.isArray(extraParameters)) {
extraParameters = [extraParameters];
}
extraParameters.push(this);
if (dom) {
dom.trigger(eventName, extraParameters);
}
return this;
}
};
/**
* Builds an instance of testReview
* @param {String|jQuery|HTMLElement} element The element on which install the component
* @param {Object} [options] A list of extra options
* @param {String} [options.region] The region on which put the component: left or right
* @param {String} [options.reviewScope] Limit the review screen to a particular scope:
* the whole test, the current test part or the current test section)
* @param {Boolean} [options.preventsUnseen] Prevents the test taker to access unseen items
* @returns {testReview}
*/
var testReviewFactory = function(element, options) {
var component = _.clone(testReview, true);
return component.init(element, options);
};
return testReviewFactory;
});