/** * 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; });