/**
* User tour control library.
*
* @module tool_usertours/usertours
* @copyright 2016 Andrew Nicols <andrew@nicols.co.uk>
*/
import BootstrapTour from './tour';
import Templates from 'core/templates';
import log from 'core/log';
import notification from 'core/notification';
import * as tourRepository from './repository';
import Pending from 'core/pending';
import {eventTypes} from './events';
let currentTour = null;
let tourId = null;
let restartTourAndKeepProgress = false;
let currentStepNo = null;
/**
* Find the first matching tour.
*
* @param {object[]} tourDetails
* @param {object[]} filters
* @returns {null|object}
*/
const findMatchingTour = (tourDetails, filters) => {
return tourDetails.find(tour => filters.some(filter => {
if (filter && filter.filterMatches) {
return filter.filterMatches(tour);
}
return true;
}));
};
/**
* Initialise the user tour for the current page.
*
* @method init
* @param {Array} tourDetails The matching tours for this page.
* @param {Array} filters The names of all client side filters.
*/
export const init = async(tourDetails, filters) => {
const requirements = [];
filters.forEach(filter => {
requirements.push(import(`tool_usertours/filter_${filter}`));
});
const filterPlugins = await Promise.all(requirements);
const matchingTour = findMatchingTour(tourDetails, filterPlugins);
if (!matchingTour) {
return;
}
// Only one tour per page is allowed.
tourId = matchingTour.tourId;
let startTour = matchingTour.startTour;
if (typeof startTour === 'undefined') {
startTour = true;
}
if (startTour) {
// Fetch the tour configuration.
fetchTour(tourId);
}
addResetLink();
// Watch for the reset link.
document.querySelector('body').addEventListener('click', e => {
const resetLink = e.target.closest('#resetpagetour');
if (resetLink) {
e.preventDefault();
resetTourState(tourId);
}
});
// Watch for the resize event.
window.addEventListener("resize", () => {
// Only listen for the running tour.
if (currentTour && currentTour.tourRunning) {
clearTimeout(window.resizedFinished);
window.resizedFinished = setTimeout(() => {
// Wait until the resize event has finished.
currentStepNo = currentTour.getCurrentStepNumber();
restartTourAndKeepProgress = true;
resetTourState(tourId);
}, 250);
}
});
};
/**
* Fetch the configuration specified tour, and start the tour when it has been fetched.
*
* @method fetchTour
* @param {Number} tourId The ID of the tour to start.
*/
const fetchTour = async tourId => {
const pendingPromise = new Pending(`admin_usertour_fetchTour:${tourId}`);
try {
// If we don't have any tour config (because it doesn't need showing for the current user), return early.
const response = await tourRepository.fetchTour(tourId);
if (response.hasOwnProperty('tourconfig')) {
const {html} = await Templates.renderForPromise('tool_usertours/tourstep', response.tourconfig);
startBootstrapTour(tourId, html, response.tourconfig);
}
pendingPromise.resolve();
} catch (error) {
pendingPromise.resolve();
notification.exception(error);
}
};
const getPreferredResetLocation = () => {
let location = document.querySelector('.tool_usertours-resettourcontainer');
if (location) {
return location;
}
location = document.querySelector('.logininfo');
if (location) {
return location;
}
location = document.querySelector('footer');
if (location) {
return location;
}
return document.body;
};
/**
* Add a reset link to the page.
*
* @method addResetLink
*/
const addResetLink = () => {
const pendingPromise = new Pending('admin_usertour_addResetLink');
Templates.render('tool_usertours/resettour', {})
.then(function(html, js) {
// Append the link to the most suitable place on the page with fallback to legacy selectors and finally the body if
// there is no better place.
Templates.appendNodeContents(getPreferredResetLocation(), html, js);
return;
})
.catch()
.then(pendingPromise.resolve)
.catch();
};
/**
* Start the specified tour.
*
* @method startBootstrapTour
* @param {Number} tourId The ID of the tour to start.
* @param {String} template The template to use.
* @param {Object} tourConfig The tour configuration.
* @return {Object}
*/
const startBootstrapTour = (tourId, template, tourConfig) => {
if (currentTour && currentTour.tourRunning) {
// End the current tour.
currentTour.endTour();
currentTour = null;
}
document.addEventListener(eventTypes.tourEnded, markTourComplete);
document.addEventListener(eventTypes.stepRenderer, markStepShown);
// Sort out the tour name.
tourConfig.tourName = tourConfig.name;
delete tourConfig.name;
// Add the template to the configuration.
// This enables translations of the buttons.
tourConfig.template = template;
tourConfig.steps = tourConfig.steps.map(function(step) {
if (typeof step.element !== 'undefined') {
step.target = step.element;
delete step.element;
}
if (typeof step.reflex !== 'undefined') {
step.moveOnClick = !!step.reflex;
delete step.reflex;
}
if (typeof step.content !== 'undefined') {
step.body = step.content;
delete step.content;
}
return step;
});
currentTour = new BootstrapTour(tourConfig);
let startAt = 0;
if (restartTourAndKeepProgress && currentStepNo) {
startAt = currentStepNo;
restartTourAndKeepProgress = false;
currentStepNo = null;
}
return currentTour.startTour(startAt);
};
/**
* Mark the specified step as being shownd by the user.
*
* @method markStepShown
* @param {Event} e
*/
const markStepShown = e => {
const tour = e.detail.tour;
const stepConfig = tour.getStepConfig(tour.getCurrentStepNumber());
tourRepository.markStepShown(
stepConfig.stepid,
tourId,
tour.getCurrentStepNumber()
).catch(log.error);
};
/**
* Mark the specified tour as being completed by the user.
*
* @method markTourComplete
* @param {Event} e
* @listens tool_usertours/stepRendered
*/
const markTourComplete = e => {
document.removeEventListener(eventTypes.tourEnded, markTourComplete);
document.removeEventListener(eventTypes.stepRenderer, markStepShown);
const tour = e.detail.tour;
const stepConfig = tour.getStepConfig(tour.getCurrentStepNumber());
tourRepository.markTourComplete(
stepConfig.stepid,
tourId,
tour.getCurrentStepNumber()
).catch(log.error);
};
/**
* Reset the state, and restart the the tour on the current page.
*
* @method resetTourState
* @param {Number} tourId The ID of the tour to start.
* @returns {Promise}
*/
export const resetTourState = tourId => tourRepository.resetTourState(tourId)
.then(response => {
if (response.startTour) {
fetchTour(response.startTour);
}
return;
}).catch(notification.exception);