@oat-sa/tao-test-runner-qti
Version:
TAO Test Runner QTI implementation
726 lines (626 loc) • 24.7 kB
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) 2016-2019 (original work) Open Assessment Technologies SA ;
*/
/**
* @author Jean-Sébastien Conan <jean-sebastien.conan@vesperiagroup.com>
*/
import $ from 'jquery';
import _ from 'lodash';
import component from 'ui/component';
import autoscroll from 'ui/autoscroll';
import mapHelper from 'taoQtiTest/runner/helpers/map';
import navigatorTpl from 'taoQtiTest/runner/plugins/navigation/review/navigator.tpl';
import navigatorTreeTpl from 'taoQtiTest/runner/plugins/navigation/review/navigatorTree';
/**
* Some default values
* @type {Object}
* @private
*/
var _defaults = {
scope: 'test',
canCollapse: false,
preventsUnseen: true,
hidden: false
};
/**
* 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',
info: 'info',
icon: 'qti-navigator-icon',
scope: {
test: 'scope-test',
testPart: 'scope-test-part',
testSection: 'scope-test-section'
}
};
/**
* List of icon CSS classes
* @type {Array}
* @private
*/
var _iconCls = [_cssCls.info, _cssCls.flagged, _cssCls.answered, _cssCls.viewed];
/**
* List of common CSS selectors
* @type {Object}
* @private
*/
var _selectors = {
component: '.qti-navigator',
filterBar: '.qti-navigator-filters',
filter: '.qti-navigator-filter',
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',
tabInfoAll: '[data-mode="all"] .qti-navigator-counter',
tabInfoUnanswered: '[data-mode="unanswered"] .qti-navigator-counter',
tabInfoFlagged: '[data-mode="flagged"] .qti-navigator-counter',
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',
activeItem: '.qti-navigator-item.active',
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)',
notInformational: ':not(.info)',
informational: '.info',
hidden: '.hidden',
disabled: '.disabled'
};
/**
* 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, _selectors.informational].join(','),
flagged: _selectors.notFlagged,
answered: _selectors.notAnswered,
filtered: _selectors.hidden
};
/**
*
* @type {Object}
*/
var navigatorApi = {
/**
* Updates the stats on the flagged items in the current map
* @param {Number} position
* @param {Boolean} flag
*/
updateStats: function updateStats(position, flag) {
var map = this.map;
var item;
if (map) {
item = mapHelper.getItemAt(map, position);
if (item) {
item.flagged = flag;
mapHelper.updateItemStats(map, position);
}
}
},
/**
* Gets the total number of items for the provided target
* @param {Object} progression
* @param {String} target
* @returns {Number}
*/
getProgressionTotal: function getProgressionTotal(progression, target) {
var total;
if ('questions' === target) {
total = progression.questions;
} else {
total = progression.total;
}
return total;
},
/**
* 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.controls.$tree.find(`[data-position=${position}]`);
var progression = this.progression;
var icon;
// update the map stats
this.updateStats(position, flag);
// update the item flag
$item.toggleClass(_cssCls.flagged, flag);
// set the item icon according to its state
icon = _.find(_iconCls, _.bind($item.hasClass, $item)) || _cssCls.unseen;
$item.find(_selectors.icons).attr('class', `${_cssCls.icon} icon-${icon}`);
// update the info panel
progression.flagged = this.controls.$tree.find(_selectors.flagged).length;
this.writeCount(
this.controls.$infoFlagged,
progression.flagged,
this.getProgressionTotal(progression, 'questions')
);
// recompute the filters
this.filter(this.currentFilter);
},
/**
* Filters the items by a criteria
* @param {String} criteria
*/
filter: function filter(criteria) {
var self = this;
// remove the current filter by restoring all items
var $items = this.controls.$tree.find(_selectors.items).removeClass(_cssCls.hidden);
// filter the items according to the provided criteria
var filterCb = _filterMap[criteria];
if (filterCb) {
$items.filter(filterCb).addClass(_cssCls.hidden);
}
// update the section counters
this.controls.$tree.find(_selectors.sections).each(function() {
var $section = $(this);
var $itemsFound = $section.find(_selectors.items).not(_selectors.hidden);
var $filtered = $itemsFound.not(_selectors.disabled);
self.writeCount($section.find(_selectors.counters), $filtered.length, $itemsFound.length);
});
this.currentFilter = criteria;
},
/**
* Update the config
* @param {Object} [config]
* @returns {navigatorApi}
*/
updateConfig: function updateConfig(config) {
var $component = this.getElement();
var scopeClass = _cssCls.scope[this.config.scope || _defaults.scope];
// apply the new config
config = _.merge(this.config, config || {});
// enable/disable the collapsing of the panel
$component.toggleClass(_cssCls.collapsible, config.canCollapse);
// update the component CSS class according to the scope
$component.removeClass(scopeClass);
scopeClass = _cssCls.scope[this.config.scope || _defaults.scope];
$component.addClass(scopeClass);
return this;
},
/**
* Keep the active item visible, auto scroll if needed
*/
autoScroll: function autoScroll() {
autoscroll(this.controls.$tree.find(_selectors.activeItem), this.controls.$tree);
},
/**
* Updates the review screen
* @param {Object} map The current test map
* @param {Object} context The current test context
* @returns {navigatorApi}
* @fires navigator#update
*/
update(map, context) {
const scopedMap = this.getScopedMap(map, context);
const testPart = mapHelper.getPart(map, context.testPartId);
const progression = scopedMap.stats || {
questions: 0,
answered: 0,
flagged: 0,
viewed: 0,
total: 0
};
const totalQuestions = this.getProgressionTotal(progression, 'questions');
this.map = map;
this.progression = progression;
// update the info panel
this.writeCount(this.controls.$infoAnswered, progression.answered, totalQuestions);
this.writeCount(this.controls.$infoUnanswered, totalQuestions - progression.answered, totalQuestions);
this.writeCount(this.controls.$infoViewed, progression.viewed, this.getProgressionTotal(progression, 'total'));
this.writeCount(this.controls.$infoFlagged, progression.flagged, totalQuestions);
this.writeCount(this.controls.$infoAll, totalQuestions, null);
// rebuild the tree
if (!testPart.isLinear) {
this.controls.$filterBar.show();
this.controls.$linearState.hide();
this.controls.$tree.html(navigatorTreeTpl(scopedMap));
this.autoScroll();
const activeItem = mapHelper.getActiveItem(scopedMap);
this.setState('prevents-unseen', this.config.preventsUnseen);
const isSkipaheadEnabled =
activeItem &&
activeItem.categories &&
_.indexOf(activeItem.categories, 'x-tao-option-review-skipahead') >= 0;
this.setState('skipahead-enabled', isSkipaheadEnabled);
if (this.config.preventsUnseen && !isSkipaheadEnabled) {
// disables all unseen items to prevent the test taker has access to.
this.controls.$tree.find(_selectors.unseen).addClass(_cssCls.disabled);
}
} else {
this.controls.$filterBar.hide();
this.controls.$linearState.show();
this.controls.$tree.empty();
}
// apply again the current filter
this.filter(this.controls.$filters.filter(_selectors.actives).data('mode'));
/**
* @event navigator#update the navigation data have changed
*/
this.trigger('update');
return this;
},
/**
* Gets the scoped map
* @param {Object} map The current test map
* @param {Object} context The current test context
* @returns {object} The scoped map
*/
getScopedMap: function getScopedMap(map, context) {
var scopedMap = mapHelper.getScopeMapFromContext(map, context, this.config.scope);
var testPart = mapHelper.getPart(scopedMap, context.testPartId) || {};
var section = mapHelper.getSection(scopedMap, context.sectionId) || {};
var item = mapHelper.getItem(scopedMap, context.itemIdentifier) || {};
// set the active part/section/item
testPart.active = true;
section.active = true;
item.active = true;
//interactive item counter
let counter = 0;
// adjust each item with additional meta
return mapHelper.each(scopedMap, function(itm) {
var cls = [];
var icon = '';
if (itm.active) {
cls.push('active');
}
if (itm.informational) {
cls.push('info');
icon = icon || 'info';
}
if (itm.flagged) {
cls.push('flagged');
icon = icon || 'flagged';
}
if (itm.answered) {
cls.push('answered');
icon = icon || 'answered';
}
if (itm.viewed) {
cls.push('viewed');
icon = icon || 'viewed';
} else {
cls.push('unseen');
icon = icon || 'unseen';
}
if(!itm.informational){
counter += 1;
itm.numberTest = counter; //item position in whole test from 1
}
itm.cls = cls.join(' ');
itm.icon = icon;
});
},
/**
* Updates a counter
* @param {jQuery} $place
* @param {Number} count
* @param {Number|Null} total
* @private
*/
writeCount: function writeCount($place, count, total) {
var display = 0;
if ($place.parent().hasClass('qti-navigator-tab')) {
display = Math.max(count, 0);
} else if (total > 0) {
display = `${Math.min(count, total)}/${total}`;
}
$place.text(display);
},
/**
* 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
*/
select: function select(position, open) {
// find the item to select and extract its hierarchy
var $tree = this.controls.$tree;
var selected = position && position.jquery ? position : $tree.find(`[data-position=${position}]`);
var hierarchy = selected.parentsUntil($tree);
var previousPosition = 0;
var $previous = $tree.find(_selectors.activeItem);
if ($previous.length) {
previousPosition = $previous.data('position');
}
// collapse the full tree and open only the hierarchy of the selected item
if (open) {
this.openOnly(hierarchy);
}
// select the item
$tree.find(_selectors.actives).removeClass(_cssCls.active);
hierarchy.add(selected).addClass(_cssCls.active);
position = selected.data('position');
/**
* An item is selected
*
* @param {Number} position - The item position on which select
* @param {Number} previousPosition - The item position from which select
* @event navigator#selected
*/
this.trigger('selected', position, previousPosition);
return selected;
},
/**
* Opens the tree on the selected item only
* @returns {jQuery} Returns the selected item
*/
openSelected: function openSelected() {
// find the selected item and extract its hierarchy
var $tree = this.controls.$tree;
var selected = $tree.find(_selectors.items + _selectors.actives);
var hierarchy = selected.parentsUntil($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
*/
openOnly: function openOnly(opened, root) {
(root || this.controls.$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 togglePanel(panel, collapseSelector) {
var collapsed = panel.hasClass(_cssCls.collapsed);
if (collapseSelector) {
this.controls.$tree.find(collapseSelector).addClass(_cssCls.collapsed);
}
if (collapsed) {
panel.removeClass(_cssCls.collapsed);
} else {
panel.addClass(_cssCls.collapsed);
}
return collapsed;
},
/**
* Toggles the display state of the component
* @param {Boolean} [show] External condition that's tells if the component must be shown or hidden
* @returns {navigatorApi}
*/
toggle: function toggle(show) {
if (typeof show === 'undefined') {
show = this.is('hidden');
}
if (show) {
this.show();
} else {
this.hide();
}
return this;
}
};
/**
*
* @param {Object} config
* @param {String} [config.scope] Limit the review screen to a particular scope: test, testPart, testSection
* @param {Boolean} [config.preventsUnseen] Prevents the test taker to access unseen items
* @param {Boolean} [config.canCollapse] Allow the test taker to collapse the component
* @param {Boolean} [config.canFlag] Allow the test taker to flag items
* @param {Boolean} [config.hidden] Hide the component at init
* @returns {*}
*/
function navigatorFactory(config) {
var navigator;
/**
* Flags an item
* @param {jQuery} $item
*/
function flagItem($item) {
var position = $item.data('position');
var flagged = !$item.hasClass(_cssCls.flagged);
// update the display
navigator.setItemFlag(position, flagged);
/**
* An item is flagged
* @event navigator#flag
* @param {Number} position - The item position on which jump
* @param {Boolean} flag - Tells whether the item is marked for review or not
*/
navigator.trigger('flag', position, flagged);
}
/**
* Jumps to an item
* @param {jQuery} $item
* @private
*/
function jump($item) {
var position = $item.data('position');
/**
* A jump to a particular item is required
* @event navigator#jump
* @param {Number} position - The item position on which jump
*/
navigator.trigger('jump', position);
}
navigator = component(navigatorApi, _defaults)
.setTemplate(navigatorTpl)
// uninstalls the component
.on('destroy', function() {
this.controls = null;
})
// keep the activ item visible
.on('show', function() {
this.autoScroll();
})
// renders the component
.on('render', function() {
var self = this;
// main component elements
var $component = this.getElement();
var $filterBar = $component.find(_selectors.filterBar);
var $filters = $filterBar.find('li');
var $tree = $component.find(_selectors.tree);
// links the component to the underlying DOM elements
this.controls = {
// access to info panel displaying counters
$infoAnswered: $component.find(_selectors.infoAnswered),
$infoViewed: $component.find(_selectors.infoViewed),
$infoAll: $component.find(_selectors.tabInfoAll),
$infoUnanswered: this.config.showLegend
? $component.find(_selectors.infoUnanswered)
: $component.find(_selectors.tabInfoUnanswered),
$infoFlagged: this.config.showLegend
? $component.find(_selectors.infoFlagged)
: $component.find(_selectors.tabInfoFlagged),
// access to filter switches
$filterBar: $filterBar,
$filters: $filters,
// access to the tree of parts/sections/items
$tree: $tree,
// access to the panel displayed when a linear part is reached
$linearState: $component.find(_selectors.linearState)
};
// apply options
this.updateConfig();
// click on the collapse handle: collapse/expand the review panel
$component.on(`click${_selectors.component}`, _selectors.collapseHandle, function() {
if (!self.is('disabled')) {
$component.toggleClass(_cssCls.collapsed);
if ($component.hasClass(_cssCls.collapsed)) {
self.openSelected();
}
}
});
// click on the info panel title: toggle the related panel
$component.on(`click${_selectors.component}`, _selectors.infoPanelLabels, function() {
if (!self.is('disabled')) {
self.togglePanel($(this).closest(_selectors.infoPanel), _selectors.infoPanel);
}
});
// click on a part title: toggle the related panel
$tree.on(`click${_selectors.component}`, _selectors.partLabels, function() {
var $panel;
if (!self.is('disabled')) {
$panel = $(this).closest(_selectors.parts);
if (self.togglePanel($panel, _selectors.parts)) {
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
$tree.on(`click${_selectors.component}`, _selectors.sectionLabels, function() {
if (!self.is('disabled')) {
self.togglePanel($(this).closest(_selectors.sections), _selectors.sections);
}
});
// click on an item: jump to the position
$tree.on(`click${_selectors.component}`, _selectors.itemLabels, function(event) {
var $item, $target;
if (!self.is('disabled')) {
$item = $(this).closest(_selectors.items);
if (!$item.hasClass(_cssCls.disabled)) {
$target = $(event.target);
if (
self.config.canFlag &&
$target.is(_selectors.icons) &&
!$component.hasClass(_cssCls.collapsed)
) {
// click on the icon, just flag the item, unless the panel is collapsed
if (!$item.hasClass(_cssCls.unseen) && !$item.hasClass(_cssCls.info)) {
flagItem($item);
}
} else if (!$item.hasClass(_cssCls.active)) {
// go to the selected item
self.select($item);
jump($item);
}
}
}
});
// click on the start button inside a linear part: jump to the position
$tree.on(`click${_selectors.component}`, _selectors.linearStart, function() {
var $btn;
if (!self.is('disabled')) {
$btn = $(this);
// go to the first item of the linear part
if (!$btn.hasClass(_cssCls.disabled)) {
$btn.addClass(_cssCls.disabled);
jump($btn);
}
}
});
// click on a filter button
$filterBar.on(`click${_selectors.component}`, _selectors.filter, function() {
var $btn, mode;
if (!self.is('disabled')) {
$btn = $(this);
mode = $btn.data('mode');
// select the button
$filters.removeClass(_cssCls.active);
$filters.attr('aria-selected', false);
$component.removeClass(_cssCls.collapsed);
$btn.addClass(_cssCls.active);
$btn.attr('aria-selected', true);
// filter the items
self.filter(mode);
//after filtering, ensure that the active item (if exists) is visible
self.autoScroll();
}
});
});
// set default filter
navigator.currentFilter = 'all';
// the component will be ready
return navigator.init(config);
}
export default navigatorFactory;