@oat-sa/tao-item-runner-qti
Version:
TAO QTI Item Runner modules
783 lines (696 loc) • 30.1 kB
JavaScript
define(['lodash', 'jquery', 'taoQtiItem/qtiItem/core/Element', 'taoQtiItem/qtiItem/helper/interactionHelper', 'ui/themeLoader', 'ui/themes', 'core/moduleLoader', 'handlebars'], function (_, $, Element, interactionHelper, themeLoader, themesHelper, moduleLoader, Handlebars) { 'use strict';
_ = _ && Object.prototype.hasOwnProperty.call(_, 'default') ? _['default'] : _;
$ = $ && Object.prototype.hasOwnProperty.call($, 'default') ? $['default'] : $;
Element = Element && Object.prototype.hasOwnProperty.call(Element, 'default') ? Element['default'] : Element;
interactionHelper = interactionHelper && Object.prototype.hasOwnProperty.call(interactionHelper, 'default') ? interactionHelper['default'] : interactionHelper;
themeLoader = themeLoader && Object.prototype.hasOwnProperty.call(themeLoader, 'default') ? themeLoader['default'] : themeLoader;
themesHelper = themesHelper && Object.prototype.hasOwnProperty.call(themesHelper, 'default') ? themesHelper['default'] : themesHelper;
moduleLoader = moduleLoader && Object.prototype.hasOwnProperty.call(moduleLoader, 'default') ? moduleLoader['default'] : moduleLoader;
Handlebars = Handlebars && Object.prototype.hasOwnProperty.call(Handlebars, 'default') ? Handlebars['default'] : Handlebars;
/*
* 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) 2014-2022 (original work) Open Assessment Technlogies SA (under the project TAO-PRODUCT);
*
*/
//webpack trick : handlebar is usale only from cjs.
//but anyway we should deprecate the practice to invoke
//directly Handlebars at runtimej
let hb = Handlebars;
if (typeof hb.compile !== 'function') {
hb = require('handlebars');
}
const _renderableClasses = [
'_container',
'assessmentItem',
'stylesheet',
'responseDeclaration',
'outcomeDeclaration',
'responseProcessing',
'_simpleFeedbackRule',
'_tooltip',
'figure',
'img',
'figcaption',
'math',
'object',
'table',
'modalFeedback',
'rubricBlock',
'associateInteraction',
'choiceInteraction',
'extendedTextInteraction',
'gapMatchInteraction',
'graphicAssociateInteraction',
'graphicGapMatchInteraction',
'graphicOrderInteraction',
'hotspotInteraction',
'hottextInteraction',
'inlineChoiceInteraction',
'matchInteraction',
'mediaInteraction',
'orderInteraction',
'selectPointInteraction',
'sliderInteraction',
'textEntryInteraction',
'uploadInteraction',
'endAttemptInteraction',
'customInteraction',
'prompt',
'associableHotspot',
'gap',
'gapImg',
'gapText',
'hotspotChoice',
'hottext',
'inlineChoice',
'simpleAssociableChoice',
'simpleChoice',
'infoControl',
'include',
'printedVariable'
];
/**
* The list of qti element dependencies. It is used internally to load dependent qti classes
*/
const _dependencies = {
assessmentItem: ['stylesheet', '_container', 'prompt', 'modalFeedback'],
rubricBlock: ['_container'],
associateInteraction: ['simpleAssociableChoice'],
choiceInteraction: ['simpleChoice'],
gapMatchInteraction: ['gap', 'gapText'],
graphicAssociateInteraction: ['associableHotspot'],
graphicGapMatchInteraction: ['associableHotspot', 'gapImg'],
graphicOrderInteraction: ['hotspotChoice'],
hotspotInteraction: ['hotspotChoice'],
hottextInteraction: ['hottext'],
inlineChoiceInteraction: ['inlineChoice'],
matchInteraction: ['simpleAssociableChoice'],
orderInteraction: ['simpleChoice']
};
/**
* The list of supported qti subclasses.
*/
const _renderableSubclasses = {
simpleAssociableChoice: ['associateInteraction', 'matchInteraction'],
simpleChoice: ['choiceInteraction', 'orderInteraction']
};
/**
* List of the default properties for the item
*/
const _defaults = {
shuffleChoices: true
};
const _isValidRenderer = function (renderer) {
let valid = true;
if (typeof renderer !== 'object') {
return false;
}
let classCorrect = false;
if (renderer.qtiClass) {
if (_.indexOf(_renderableClasses, renderer.qtiClass) >= 0) {
classCorrect = true;
} else {
const pos = renderer.qtiClass.indexOf('.');
if (pos > 0) {
const qtiClass = renderer.qtiClass.slice(0, pos);
const subClass = renderer.qtiClass.slice(pos + 1);
if (_renderableSubclasses[qtiClass] && _.indexOf(_renderableSubclasses[qtiClass], subClass) >= 0) {
classCorrect = true;
}
}
}
}
if (!classCorrect) {
valid = false;
throw new Error('invalid qti class name in renderer declaration : ' + renderer.qtiClass);
}
if (!renderer.template) {
valid = false;
throw new Error('missing template in renderer declaration : ' + renderer.qtiClass);
}
return valid;
};
/**
* Get the location of the document, useful to define a baseUrl for the required context
* @returns {String}
*/
function getDocumentBaseUrl() {
return window.location.protocol + '//' + window.location.host + window.location.pathname.replace(/([^\/]*)$/, '');
}
/**
* The built Renderer class
* @constructor
* @param {Object} [options] - the renderer options
* @param {AssetManager} [options.assetManager] - The renderer needs an AssetManager to resolve URLs (see {@link taoItems/assets/manager})
* @param {Boolean} [options.shuffleChoices = true] - Does the renderer take care of the shuffling
* @param {Object} [options.decorators] - to set up rendering decorator
* @param {preRenderDecorator} [options.decorators.before] - to set up a pre decorator
* @param {postRenderDecorator} [options.decorators.after] - to set up a post decorator
*/
const Renderer = function (options) {
/**
* Store the registered renderer location
*/
const _locations = {};
/**
* Store loaded renderers
*/
const _renderers = {};
options = _.defaults(options || {}, _defaults);
this.isRenderer = true;
this.name = '';
//store shuffled choice here
this.shuffledChoices = [];
/**
* Get the actual renderer of the give qti class or subclass:
* e.g. simplceChoice, simpleChoice.choiceInteraction, simpleChoice.orderInteraction
*/
const _getClassRenderer = function (qtiClass) {
let ret = null;
if (_renderers[qtiClass]) {
ret = _renderers[qtiClass];
} else {
const pos = qtiClass.indexOf('.');
if (pos > 0) {
qtiClass = qtiClass.slice(0, pos);
if (_renderers[qtiClass]) {
ret = _renderers[qtiClass];
}
}
}
return ret;
};
/**
* Registers a QTI renderer class
* @param {String} qtiClass
* @param {Array} list
* @returns {Boolean} `true` if the class has been successfully registered
*/
function registerRendererClass(qtiClass, list) {
let success = false;
if (_locations[qtiClass] === false) {
//mark this class as not renderable
_renderers[qtiClass] = false;
success = true;
} else if (_locations[qtiClass]) {
list.push(_locations[qtiClass]);
success = true;
}
return success;
}
/**
* Set the renderer options
* @param {String} key - the name of the option
* @param {*} value - the option vallue
* @returns {Renderer} for chaining
*/
this.setOption = function (key, value) {
if (typeof key === 'string') {
options[key] = value;
}
return this;
};
/**
* Set the renderer options
* @param {Object} opts - the options
* @returns {Renderer} for chaining
*/
this.setOptions = function (opts) {
options = _.extend(options, opts);
return this;
};
/**
* Get the renderer option
* @param {String} key - the name of the option
* @returns {*|null} the option vallue
*/
this.getOption = function (key) {
if (typeof key === 'string' && options[key]) {
return options[key];
}
return null;
};
this.getCustomMessage = function getCustomMessage(elementName, messageKey) {
const messages = this.getOption('messages');
if (messages && elementName && messages[elementName] && _.isString(messages[elementName][messageKey])) {
//currently not translatable but potentially could be if the need raises
return hb.compile(messages[elementName][messageKey]);
} else {
return null;
}
};
/**
* Get the bound assetManager
* @returns {AssetManager} the assetManager
*/
this.getAssetManager = function getAssetManager() {
return options.assetManager;
};
/**
* Get the bound theme loader
* @returns {Object} the themeLoader
*/
this.getThemeLoader = function getThemeLoader() {
return this.themeLoader;
};
/**
* Renders the template
* @param {Object} element - the QTI model element
* @param {Object} data - the data to give to the template
* @param {String} [qtiSubclass] - to get the render of the element subclass (when element's qtiClass is abstract)
* @returns {String} the template results
* @throws {Error} if the renderer is not set or has no template bound
*/
this.renderTpl = function (element, data, qtiSubclass) {
let res;
let ret = '';
const qtiClass = qtiSubclass || element.qtiClass;
const renderer = _getClassRenderer(qtiClass);
const decorators = this.getOption('decorators');
if (!renderer || !_.isFunction(renderer.template)) {
throw new Error('no renderer template loaded under the class name : ' + qtiClass);
}
//pre rendering decoration
if (_.isObject(decorators) && _.isFunction(decorators.before)) {
/**
* @callback preRenderDecoractor
* @param {Object} element - the QTI model element
* @param {String} [qtiSubclass] - to get the render of the element subclass (when element's qtiClass is abstract)
* @returns {String} the decorator result
*/
res = decorators.before(element, qtiSubclass);
if (_.isString(res)) {
ret += res;
}
}
//render the template
ret += renderer.template(data);
//post rendering decoration
if (_.isObject(decorators) && _.isFunction(decorators.after)) {
/**
* @callback postRenderDecoractor
* @param {Object} element - the QTI model element
* @param {String} [qtiSubclass] - to get the render of the element subclass (when element's qtiClass is abstract)
* @returns {String} the decorator result
*/
res = decorators.after(element, qtiSubclass);
if (_.isString(res)) {
ret += res;
}
}
return ret;
};
this.getData = function (element, data, qtiSubclass) {
let ret = data;
const qtiClass = qtiSubclass || element.qtiClass;
const renderer = _getClassRenderer(qtiClass);
if (renderer) {
if (typeof renderer.getData === 'function') {
ret = renderer.getData.call(this, element, data);
}
}
return ret;
};
this.renderDirect = function (tpl, data) {
return hb.compile(tpl)(data);
};
this.getContainer = function (qtiElement, $scope, qtiSubclass) {
let ret = null;
const qtiClass = qtiSubclass || qtiElement.qtiClass;
const renderer = _getClassRenderer(qtiClass);
if (renderer) {
ret = renderer.getContainer(qtiElement, $scope);
} else {
throw new Error('no renderer found for the class : ' + qtiElement.qtiClass);
}
return ret;
};
this.postRender = function (qtiElement, data, qtiSubclass) {
const qtiClass = qtiSubclass || qtiElement.qtiClass;
const renderer = _getClassRenderer(qtiClass);
if (renderer && typeof renderer.render === 'function') {
return renderer.render.call(this, qtiElement, data);
}
};
this.setResponse = function (qtiInteraction, response, qtiSubclass) {
let ret = false;
const qtiClass = qtiSubclass || qtiInteraction.qtiClass;
const renderer = _getClassRenderer(qtiClass);
if (renderer) {
if (typeof renderer.setResponse === 'function') {
ret = renderer.setResponse.call(this, qtiInteraction, response);
const $container = renderer.getContainer.call(this, qtiInteraction);
if ($container instanceof $ && $container.length) {
$container.trigger('responseSet', [qtiInteraction, response]);
}
}
} else {
throw new Error('no renderer registered under the name : ' + qtiClass);
}
return ret;
};
this.getResponse = function (qtiInteraction, qtiSubclass) {
let ret = false;
const qtiClass = qtiSubclass || qtiInteraction.qtiClass;
const renderer = _getClassRenderer(qtiClass);
if (renderer) {
if (typeof renderer.getResponse === 'function') {
ret = renderer.getResponse.call(this, qtiInteraction);
}
} else {
throw new Error('no renderer registered under the name : ' + qtiClass);
}
return ret;
};
this.resetResponse = function (qtiInteraction, qtiSubclass) {
let ret = false;
const qtiClass = qtiSubclass || qtiInteraction.qtiClass;
const renderer = _getClassRenderer(qtiClass);
if (renderer) {
if (typeof renderer.resetResponse === 'function') {
ret = renderer.resetResponse.call(this, qtiInteraction);
}
} else {
throw new Error('no renderer registered under the name : ' + qtiClass);
}
return ret;
};
/**
* Retrieve the state of the interaction.
* If the renderer has no state management, it falls back to the response management.
*
* @param {Object} qtiInteraction - the interaction
* @param {String} [qtiSubClass] - (not sure of the type and how it is used - Sam ? )
* @returns {Object} the interaction's state
*
* @throws {Error} if no renderer is registered
*/
this.getState = function (qtiInteraction, qtiSubclass) {
let ret = false;
const qtiClass = qtiSubclass || qtiInteraction.qtiClass;
const renderer = _getClassRenderer(qtiClass);
if (renderer) {
if (_.isFunction(renderer.getState)) {
ret = renderer.getState.call(this, qtiInteraction);
} else {
ret = renderer.getResponse.call(this, qtiInteraction);
}
} else {
throw new Error('no renderer registered under the name : ' + qtiClass);
}
return ret;
};
/**
* Retrieve the state of the interaction.
* If the renderer has no state management, it falls back to the response management.
*
* @param {Object} qtiInteraction - the interaction
* @param {Object} state - the interaction's state
* @param {String} [qtiSubClass] - (not sure of the type and how it is used - Sam ? )
*
* @throws {Error} if no renderer is found
*/
this.setState = function (qtiInteraction, state, qtiSubclass) {
const qtiClass = qtiSubclass || qtiInteraction.qtiClass;
const renderer = _getClassRenderer(qtiClass);
if (renderer) {
if (_.isFunction(renderer.setState)) {
renderer.setState.call(this, qtiInteraction, state);
} else {
renderer.resetResponse.call(this, qtiInteraction);
renderer.setResponse.call(this, qtiInteraction, state);
}
} else {
throw new Error('no renderer registered under the name : ' + qtiClass);
}
};
/**
* Calls the renderer destroy.
* Ask the renderer to run destroy if exists.
*
* @throws {Error} if no renderer is found
*/
this.destroy = function (qtiInteraction, qtiSubclass) {
let ret = false;
const qtiClass = qtiSubclass || qtiInteraction.qtiClass;
const renderer = _getClassRenderer(qtiClass);
if (renderer) {
if (_.isFunction(renderer.destroy)) {
ret = renderer.destroy.call(this, qtiInteraction);
}
} else {
throw new Error('no renderer registered under the name : ' + qtiClass);
}
return ret;
};
this.getLoadedRenderers = function () {
return _renderers;
};
this.register = function (renderersLocations) {
_.extend(_locations, renderersLocations);
};
this.load = function (callback, requiredClasses) {
const self = this;
let required = [];
const themeData = themesHelper.getCurrentThemeData();
if (themeData) {
options.themes = themeData;
}
if (options.themes) {
//resolve themes paths
options.themes.base = this.resolveUrl(options.themes.base);
_.forEach(options.themes.available, function (theme, index) {
options.themes.available[index].path = self.resolveUrl(theme.path);
});
this.themeLoader = themeLoader(options.themes).load(options.preload);
}
if (requiredClasses) {
if (_.isArray(requiredClasses)) {
//exclude abstract classes
requiredClasses = _.intersection(requiredClasses, _renderableClasses);
//add dependencies
_.forEach(requiredClasses, function (reqClass) {
const deps = _dependencies[reqClass];
if (deps) {
requiredClasses = _.union(requiredClasses, deps);
}
});
_.forEach(requiredClasses, function (qtiClass) {
let requiredSubClasses;
if (_renderableSubclasses[qtiClass]) {
requiredSubClasses = _.intersection(requiredClasses, _renderableSubclasses[qtiClass]);
_.forEach(requiredSubClasses, function (subclass) {
if (
!registerRendererClass(qtiClass + '.' + subclass, required) &&
!registerRendererClass(qtiClass, required)
) {
throw new Error(
self.name +
' : missing qti class location declaration: ' +
qtiClass +
', subclass: ' +
subclass
);
}
});
} else {
if (!registerRendererClass(qtiClass, required)) {
throw new Error(self.name + ' : missing qti class location declaration: ' + qtiClass);
}
}
});
} else {
throw new Error('invalid argument type: expected array for arg "requireClasses"');
}
} else {
required = _.values(_locations);
}
moduleLoader([], () => true)
.addList(required.map(module => ({ module, category: 'qti' })))
.load()
.then(loaded => {
loaded.forEach(clazz => {
if (_isValidRenderer(clazz)) {
_renderers[clazz.qtiClass] = clazz;
}
});
if (typeof callback === 'function') {
callback.call(self, _renderers);
}
});
return this;
};
/**
* Unload the renderer
*/
this.unload = function unload() {
if (this.themeLoader) {
themeLoader(options.themes).unload();
}
return this;
};
/**
* Define the shuffling manually
*
* @param {Object} interaction - the interaction
* @param {Array} choices - the shuffled choices
* @param {String} identificationType -
*/
this.setShuffledChoices = function (interaction, choices, identificationType) {
if (Element.isA(interaction, 'interaction')) {
this.shuffledChoices[interaction.getSerial()] = interactionHelper
.findChoices(interaction, choices, identificationType)
.map(val => val.serial);
}
};
/**
* Get the choices shuffled for this interaction.
*
* @param {Object} interaction - the interaction
* @param {Boolean} reshuffle - by default choices are shuffled only once and store, but if true you can force shuffling again
* @param {String} returnedType - the choice type
* @returns {Array} the choices
*/
this.getShuffledChoices = function (interaction, reshuffle, returnedType) {
let choices = [];
let serial, i;
if (Element.isA(interaction, 'interaction')) {
serial = interaction.getSerial();
//1st time, we shuffle (or asked to force shuffling)
if (!this.shuffledChoices[serial] || reshuffle) {
if (Element.isA(interaction, 'matchInteraction')) {
this.shuffledChoices[serial] = [];
for (i = 0; i < 2; i++) {
choices[i] = interactionHelper.shuffleChoices(interaction.getChoices(i));
this.shuffledChoices[serial][i] = choices[i].map(val => val.serial);
}
} else {
choices = interactionHelper.shuffleChoices(interaction.getChoices());
this.shuffledChoices[serial] = choices.map(val => val.serial);
}
//otherwise get the choices from the serials
} else {
if (Element.isA(interaction, 'matchInteraction')) {
_.forEach(choices, function (choice, index) {
if (index < 2) {
_.forEach(this.shuffledChoices[serial][i], function (choiceSerial) {
choice.push(interaction.getChoice(choiceSerial));
});
}
});
} else {
_.forEach(this.shuffledChoices[serial], function (choiceSerial) {
choices.push(interaction.getChoice(choiceSerial));
});
}
}
//do some type convertion
if (returnedType === 'serial' || returnedType === 'identifier') {
return interactionHelper.convertChoices(choices, returnedType);
}
//pass value only, not ref
return _.clone(choices);
}
return [];
};
this.getRenderers = function () {
return _renderers;
};
this.getLocations = function () {
return _locations;
};
/**
* Resolve URLs using the defined assetManager's strategies
* @param {String} url - the URL to resolve
* @returns {String} the resolved URL
*/
this.resolveUrl = function resolveUrl(url) {
if (!options.assetManager) {
return url;
}
if (typeof url === 'string' && url.length > 0) {
return options.assetManager.resolve(url);
}
};
/**
* @deprecated in favor of resolveUrl
*/
this.getAbsoluteUrl = function (relUrl) {
//let until method is removed
console.warn('DEPRECATED used getAbsoluteUrl with ', arguments);
//allow relative url output only if explicitely said so
if (this.getOption('userRelativeUrl')) {
return relUrl.replace(/^\.?\//, '');
}
if (/^http(s)?:\/\//i.test(relUrl) || /^data:[^\/]+\/[^;]+(;charset=[\w]+)?;base64,/.test(relUrl)) {
//already absolute or base64 encoded
return relUrl;
} else {
let absUrl = '';
const runtimeLocations = this.getOption('runtimeLocations');
if (runtimeLocations && _.size(runtimeLocations)) {
_.forIn(runtimeLocations, function (runtimeLocation, typeIdentifier) {
if (relUrl.indexOf(typeIdentifier) === 0) {
absUrl = relUrl.replace(typeIdentifier, runtimeLocation);
return false; //break
}
});
}
if (absUrl) {
return absUrl;
} else {
const baseUrl = this.getOption('baseUrl') || getDocumentBaseUrl();
return baseUrl + relUrl;
}
}
};
this.setAreaBroker = function setAreaBroker(areaBroker) {
this._areaBroker = areaBroker;
};
this.getAreaBroker = function getAreaBroker() {
if (this._areaBroker) {
return this._areaBroker;
}
};
this.getItemCreator = function getItemCreator() {
return this.getOption('itemCreator');
};
};
/**
* Expose the renderer's factory
* @exports taoQtiItem/qtiRunner/core/Renderer
*/
var Renderer$1 = {
/**
* Creates a new Renderer by extending the Renderer's prototype
* @param {Object} renderersLocations -
* @param {String} [name] - the new renderer name
* @param {Object} [defaultOptions] - the renderer options
*/
build: function (renderersLocations, name, defaultOptions) {
const NewRenderer = function () {
const options = _.isPlainObject(arguments[0]) ? arguments[0] : {};
Renderer.apply(this);
this.register(renderersLocations);
this.name = name || '';
this.setOptions(_.defaults(options, defaultOptions || {}));
};
NewRenderer.prototype = Renderer.prototype;
return NewRenderer;
}
};
return Renderer$1;
});