446 lines
18 KiB
JavaScript
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;
|
||
|
});
|