admin/tool/usertours/amd/src/usertours.js

/**
 * 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);