tao-test/app/taoQtiItem/views/js/qtiCreator/itemCreator.js

446 lines
18 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) 2016-2021 (original work) Open Assessment Technologies SA ;
*
*/
/**
* The item creator factory let's you create (guess what...)
*
* The item creator is "unfinished" because all parts aren't yet independants and the loading is anarhic,
* however the item creator does a 1st job of wrapping the item creator's bootstrap.
*
*
* @author Bertrand Chevrier <bertrand@taotesting.com>
*/
define([
'jquery',
'lodash',
'i18n',
'module',
'core/eventifier',
'core/promise',
'taoQtiItem/portableElementRegistry/ciRegistry',
'taoQtiItem/portableElementRegistry/icRegistry',
'taoQtiItem/qtiCreator/context/qtiCreatorContext',
'taoQtiItem/qtiCreator/helper/itemLoader',
'taoQtiItem/qtiCreator/helper/creatorRenderer',
'taoQtiItem/qtiCreator/helper/commonRenderer', //for read-only element : preview + xinclude
'taoQtiItem/qtiCreator/helper/xincludeRenderer',
'taoQtiItem/qtiCreator/editor/interactionsPanel',
'taoQtiItem/qtiCreator/editor/propertiesPanel',
'taoQtiItem/qtiCreator/model/helper/event',
'taoQtiItem/qtiCreator/editor/styleEditor/styleEditor'
], function (
$,
_,
__,
module,
eventifier,
Promise,
ciRegistry,
icRegistry,
qtiCreatorContextFactory,
itemLoader,
creatorRenderer,
commonRenderer,
xincludeRenderer,
interactionPanel,
propertiesPanel,
eventHelper,
styleEditor
) {
'use strict';
/**
* Load an item
* @param {String} uri - the item URI
* @param {String} label - the item label
* @param {String} itemDataUrl - the data url
* @param {Boolean} perInteractionRp - per interaction processing enabled
*
* @returns {Promise} that resolve with the loaded item model
*/
const loadItem = function loadItem(uri, label, itemDataUrl, perInteractionRp) {
return new Promise(function (resolve, reject) {
itemLoader.loadItem(
{ uri: uri, label: label, itemDataUrl: itemDataUrl, perInteractionRp },
function (item) {
if (!item) {
reject(new Error('Unable to load the item'));
}
//set useful data :
item.data('uri', uri);
resolve(item);
}
);
});
};
/**
* load custom interactions registered from the custom interaction registry
*
* @param {Array} interactionsIds
* @returns {Promise} that resolve with the loaded item model
*/
const loadCustomInteractions = function loadCustomInteractions(interactionsIds) {
return ciRegistry.loadCreators({
include: interactionsIds
});
};
/**
* load info controls registered from the info control registry
*
* @returns {Promise} that resolve with the loaded item model
*/
const loadInfoControls = function loadInfoControls() {
return icRegistry.loadCreators();
};
/**
* Build a new Item Creator
* @param {Object} config - the creator's configuration
* @param {String} config.properties.uri - the URI of the item to load (properties structure is kept as legacy)
* @param {String} config.properties.label - the label of the item to load (properties structure is kept as legacy)
* @param {String} config.properties.baseUrl - the base URL used to resolve assets
* @param {Boolean} config.properties.perInteractionRp - per interaction response processing enabled
* @param {String[]} [config.interactions] - the list of additional interactions to load (PCI)
* @param {String[]} [config.infoControls] - the list of info controls to load (PIC)
* @param {areaBroker} areaBroker - a mapped areaBroker
* @param {Function[]} pluginFactories - the plugin's factory, ready to be instantiated
* @returns {itemCreator} an event emitter object, with the usual lifecycle
* @throws {TypeError}
*/
const itemCreatorFactory = function itemCreatorFactory(config, areaBroker, pluginFactories) {
let itemCreator;
const qtiCreatorContext = qtiCreatorContextFactory();
const plugins = {};
/**
* Run a method in all plugins
*
* @param {String} method - the method to run
* @returns {Promise} once that resolve when all plugins are done
*/
const pluginRun = function pluginRun(method) {
const execStack = [];
_.forEach(plugins, function (plugin) {
if (_.isFunction(plugin[method])) {
execStack.push(plugin[method]());
}
});
return Promise.all(execStack);
};
//validate parameters
if (!_.isPlainObject(config)) {
throw new TypeError('The item creator configuration is required');
}
if (
!config.properties ||
_.isEmpty(config.properties.uri) ||
_.isEmpty(config.properties.label) ||
_.isEmpty(config.properties.baseUrl)
) {
throw new TypeError(
'The creator configuration must contains the required properties triples: uri, label and baseUrl'
);
}
if (!areaBroker) {
throw new TypeError('Without an areaBroker there are no chance to see something you know');
}
//factor the new itemCreator
itemCreator = eventifier({
//lifecycle
/**
* Initialize the item creator:
* - set up the registries for portable elements
* - load the item
* - instantiate and initialize the plugins
*
* @returns {itemCreator} chains
* @fires itemCreator#init - once initialized
* @fires itemCreator#error - if something went wrong
*/
init: function init() {
const self = this;
//instantiate the plugins first
_.forEach(pluginFactories, function (pluginFactory) {
const plugin = pluginFactory(self, areaBroker);
plugins[plugin.getName()] = plugin;
});
// quick-fix: clear all ghost events listeners
// prevent ghosting of item states and other properties
$(document).off('.qti-widget');
/**
* Save the item on "save" event
* @event itemCreator#save
* @param {Boolean} [silent] - true to not trigger the success feedback
* @fires itemCreator#saved once the save is done
* @fires itemCreator#error
*/
this.on('save', function (silent) {
const item = this.getItem();
const itemWidget = item.data('widget');
const invalidElements = item.data('invalid') || {};
if (_.size(invalidElements)) {
const reasons = [];
Object.keys(invalidElements).forEach(serial => {
Object.keys(invalidElements[serial]).forEach(key => {
reasons.push(invalidElements[serial][key].message);
});
});
self.trigger('error', new Error(`${__('Item cannot be saved.')} ${reasons.join(', ')}.`));
return;
}
//do the save
return this.beforeSaveProcess
.then(() => styleEditor.save())
.then(() => itemWidget.save())
.then(() => {
if (!silent){
self.trigger('success', __('Your item has been saved'));
}
self.trigger('saved');
}).catch(err => {
self.trigger('error', err);
});
});
this.on('exit', function () {
$('.item-editor-item', areaBroker.getItemPanelArea()).empty();
styleEditor.cleanCache();
});
const usedCustomInteractionIds = [];
loadItem(
config.properties.uri,
config.properties.label,
config.properties.itemDataUrl,
config.properties.perInteractionRp
)
.then(function (item) {
if (!_.isObject(item)) {
self.trigger('error', new Error(`Unable to load the item ${config.properties.label}`));
return;
}
_.forEach(item.getComposingElements(), function (element) {
if (element.is('customInteraction')) {
usedCustomInteractionIds.push(element.typeIdentifier);
}
});
self.item = item;
return true;
})
.then(() => {
const item = self.item;
// To migrate old test items to use per interaction response processing
// missing aoutcome declarations should be added
if (
item.responseProcessing.processingType === 'templateDriven' &&
config.properties.perInteractionRp
) {
const responseIdentifiers = Object.keys(item.responses || {}).map(
responseKey => item.responses[responseKey].attributes.identifier
);
_.forEach(responseIdentifiers, responseIdentifier => {
const outcomeIdentifier = `SCORE_${responseIdentifier}`;
if (!item.getOutcomeDeclaration(outcomeIdentifier)) {
item.createOutcomeDeclaration({
cardinality: 'single',
baseType: 'float'
}).attr('identifier', outcomeIdentifier);
}
});
}
})
.then(function () {
//load custom elements
return Promise.all([loadCustomInteractions(usedCustomInteractionIds), loadInfoControls()]);
})
.then(function () {
//initialize all the plugins
return pluginRun('init').then(function () {
/**
* @event itemCreator#init the initialization is done
* @param {Object} item - the loaded item
*/
self.trigger('init', self.item);
});
})
.then(function () {
// forward context error
qtiCreatorContext.on('error', function (err) {
self.trigger('error', err);
});
// handle before save processes
self.beforeSaveProcess = Promise.resolve();
qtiCreatorContext.on('registerBeforeSaveProcess', beforeSaveProcess => {
self.beforeSaveProcess = Promise.all([self.beforeSaveProcess, beforeSaveProcess]);
});
return qtiCreatorContext.init();
})
.catch(function (err) {
self.trigger('error', err);
});
return this;
},
/**
* Renders the creator
* Because of the actual structure, it also intialize some components (panels, toolbars, etc.).
*
* @returns {itemCreator} chains
* @fires itemCreator#render - once everything is in place
* @fires itemCreator#ready - once the creator's components' are ready (not yet reliable)
* @fires itemCreator#error - if something went wrong
*/
render: function render() {
const self = this;
const item = this.getItem();
if (!item || !_.isFunction(item.getUsedClasses)) {
return this.trigger('error', new Error('We need an item to render.'));
}
//configure commonRenderer for the preview and initial qti element rendering
commonRenderer.setContext(areaBroker.getItemPanelArea());
commonRenderer.get(true, config).setOption('baseUrl', config.properties.baseUrl);
interactionPanel(areaBroker.getInteractionPanelArea());
//the renderers' widgets do not handle async yet, so we rely on this event
//TODO ready should be triggered once every renderer's widget is done (ie. promisify everything)
$(document).on('ready.qti-widget', function (e, elt) {
if (elt.element.qtiClass === 'assessmentItem') {
self.trigger('ready');
}
});
// pass an context reference to the renderer
config.qtiCreatorContext = qtiCreatorContext;
creatorRenderer
.get(true, config, areaBroker)
.setOptions(config.properties)
.load(function () {
let widget;
//set renderer
item.setRenderer(this);
//render item (body only) into the "drop-area"
areaBroker.getItemPanelArea().append(item.render());
//"post-render it" to initialize the widget
Promise.all(item.postRender(_.clone(config.properties)))
.then(function () {
//set reference to item widget object
areaBroker.getContainer().data('widget', item);
widget = item.data('widget');
_.each(item.getComposingElements(), function (element) {
if (element.qtiClass === 'include') {
xincludeRenderer.render(element.data('widget'), config.properties.baseUrl);
}
});
propertiesPanel(areaBroker.getPropertyPanelArea(), widget, config.properties);
//init event listeners:
eventHelper.initElementToWidgetListeners();
return pluginRun('render').then(function () {
self.trigger('render');
});
})
.catch(function (err) {
self.trigger('error', err);
});
}, item.getUsedClasses());
return this;
},
/**
* Clean up everything and destroy the item creator
*
* @returns {itemCreator} chains
*/
destroy: function destroy() {
$(document).off('.qti-widget');
pluginRun('destroy')
.then(() => qtiCreatorContext.destroy())
.then(() => {
this.trigger('destroy');
})
.catch(err => {
this.trigger('error', err);
});
return this;
},
//accessors
/**
* Give an access to the loaded item
* @returns {Object} the item
*/
getItem: function getItem() {
return this.item;
},
/**
* Return if item is empty or not
* @returns {Boolean} true/false
*/
isEmpty: function isEmpty() {
const item = this.item.bdy.bdy;
return item === '' || item === '\n ';
},
/**
* Give an access to the config
* @returns {Object} the config
*/
getConfig: function getConfig() {
return config;
}
});
return itemCreator;
};
return itemCreatorFactory;
});