benmarch/angular-ui-tour

View on GitHub
app/tour-controller.js

Summary

Maintainability
F
3 days
Test Coverage
import angular from 'angular';
import EventEmitter from 'events';

export default function uiTourController($timeout, $q, $filter, $document, TourConfig, uiTourBackdrop, uiTourService, TourStepService, hotkeys) {
    'ngInject';

    var self = new EventEmitter(),
        stepList = [],
        currentStep = null,
        resumeWhenFound,
        TourStatus = {
            OFF: 0,
            ON: 1,
            PAUSED: 2,
            WAITING: 3
        },
        tourStatus = TourStatus.OFF,
        options = TourConfig.getAll();

    //polyfill "off"
    self.off = self.removeListener;

    /**
     * Closer to $evalAsync, just resolves a promise
     * after the next digest cycle
     *
     * @returns {Promise}
     */
    function digest() {
        return $q.resolve();
    }

    /**
     * return current step or null
     * @returns {step}
     */
    function getCurrentStep() {
        return currentStep;
    }

    /**
     * set the current step (doesnt do anything else)
     * @param {step} step Current step
     */
    function setCurrentStep(step) {
        currentStep = step;
    }

    /**
     * gets a step relative to current step
     *
     * @param {number} offset Positive integer to search right, negative to search left
     * @returns {step}
     */
    function getStepByOffset(offset) {
        if (!getCurrentStep()) {
            return null;
        }
        if (getCurrentStep().config('nextPath') && offset > 0) {
            return null;
        }
        if (getCurrentStep().config('prevPath') && offset < 0) {
            return null;
        }

        return stepList[stepList.indexOf(getCurrentStep()) + offset];
    }

    /**
     * retrieves a step (if it exists in the step list) by index, ID, or identity
     * Note: I realize ID is short for identity, but ID is really the step name here
     *
     * @param {string | number | step} stepOrStepIdOrIndex Step to retrieve
     * @returns {step}
     */
    function getStep(stepOrStepIdOrIndex) {
        //index
        if (angular.isNumber(stepOrStepIdOrIndex)) {
            return stepList[stepOrStepIdOrIndex];
        }

        //ID string
        if (angular.isString(stepOrStepIdOrIndex)) {
            return stepList.filter(function (step) {
                return step.stepId === stepOrStepIdOrIndex;
            })[0];
        }

        //object
        if (angular.isObject(stepOrStepIdOrIndex)) {
            //step identity
            if (~stepList.indexOf(stepOrStepIdOrIndex)) {
                return stepOrStepIdOrIndex;
            }

            //step copy
            if (stepOrStepIdOrIndex.stepId) {
                return stepList.filter(function (step) {
                    return step.stepId === stepOrStepIdOrIndex.stepId;
                })[0];
            }
        }

        return null;
    }

    /**
     * return next step or null
     * @returns {step}
     */
    function getNextStep() {
        return getStepByOffset(+1);
    }

    /**
     * return previous step or null
     * @returns {step}
     */
    function getPrevStep() {
        return getStepByOffset(-1);
    }

    /**
     * is there a next step
     *
     * @returns {boolean}
     */
    function isNext() {
        //not using .config('onNext') because we are looking only for config on the step and not the tour
        return !!(getNextStep() || (getCurrentStep() && (getCurrentStep().config('nextPath') || getCurrentStep().onNext)));
    }

    /**
     * is there a previous step
     *
     * @returns {boolean}
     */
    function isPrev() {
        //not using .config('onPrev') because we are looking only for config on the step and not the tour
        return !!(getPrevStep() || (getCurrentStep() && (getCurrentStep().config('prevPath') || getCurrentStep().onPrev)));
    }

    /**
     * A safe way to invoke a possibly null event handler
     *
     * @param handler
     * @returns {*}
     */
    function handleEvent(handler) {
        return (handler || digest)();
    }

    /**
     * Configures hot keys for controlling the tour with the keyboard
     */
    function setHotKeys() {
        hotkeys.add({
            combo: 'esc',
            description: 'End tour',
            callback: function () {
                self.end();
            }
        });

        hotkeys.add({
            combo: 'right',
            description: 'Go to next step',
            callback: function () {
                if (isNext()) {
                    self.next();
                }
            }
        });

        hotkeys.add({
            combo: 'left',
            description: 'Go to previous step',
            callback: function () {
                if (isPrev()) {
                    self.prev();
                }
            }
        });
    }

    /**
     * Turns off hot keys for when the tour isn't running
     */
    function unsetHotKeys() {
        hotkeys.del('esc');
        hotkeys.del('right');
        hotkeys.del('left');
    }

    //---------------- Protected API -------------------
    /**
     * Adds a step to the tour in order
     *
     * @protected
     * @param {object} step
     */
    self.addStep = function (step) {
        if (~stepList.indexOf(step)) {
            return;
        }
        stepList.push(step);
        stepList = $filter('orderBy')(stepList, 'order');
        self.emit('stepAdded', step);
        if (resumeWhenFound) {
            resumeWhenFound(step);
        }
    };

    /**
     * Removes a step from the tour
     *
     * @protected
     * @param step
     */
    self.removeStep = function (step) {
        var index = stepList.indexOf(step);

        if (index !== -1) {
            stepList.splice(index, 1);
            self.emit('stepRemoved', step);
        }
    };

    /**
     * if a step's order was changed, replace it in the list
     *
     * @protected
     * @param step
     */
    self.reorderStep = function (step) {
        self.removeStep(step);
        self.addStep(step);
        self.emit('stepsReordered', step);
    };

    /**
     * Checks to see if a step exists by ID, index, or identity
     *
     * @protected
     * @param {string | number | step} stepOrStepIdOrIndex Step to check
     * @returns {boolean}
     */
    self.hasStep = function (stepOrStepIdOrIndex) {
        return !!getStep(stepOrStepIdOrIndex);
    };

    /**
     * show supplied step
     *
     * @protected
     * @param step
     * @returns {promise}
     */
    self.showStep = async function (step) {
        if (!step) {
            throw 'No step.';
        }

        await handleEvent(step.config('onShow'));

        TourStepService.showStep(step, self);

        await digest();
        await handleEvent(step.config('onShown'));

        self.emit('stepShown', step);
        step.isNext = isNext;
        step.isPrev = isPrev;
    };

    /**
     * hides the supplied step
     *
     * @protected
     * @param step
     * @returns {Promise}
     */
    self.hideStep = async function (step) {
        if (!step) {
            throw 'No step.';
        }

        await handleEvent(step.config('onHide'));

        TourStepService.hideStep(step);

        await digest();
        await handleEvent(step.config('onHidden'));

        self.emit('stepHidden', step);
    };

    /**
     * Returns the value for specified option
     *
     * @protected
     * @param {string} option Name of option
     * @returns {*}
     */
    self.config = function (option) {
        return options[option];
    };

    /**
     * pass options from directive
     *
     * @protected
     * @param opts
     * @returns {self}
     */
    self.init = function (opts) {
        options = angular.extend(options, opts);
        self.options = options;
        uiTourService._registerTour(self);
        self.initialized = true;
        self.emit('initialized');
        return self;
    };

    /**
     * Unregisters with the tour service when tour is destroyed
     *
     * @protected
     */
    self.destroy = function () {
        uiTourService._unregisterTour(self);
    };
    //------------------ end Protected API ------------------


    //------------------ Public API ------------------

    /**
     * starts the tour
     *
     * @returns {Promise}
     */
    self.start = function () {
        return self.startAt(0);
    };

    /**
     * starts the tour at a specified step, step index, or step ID
     *
     * @public
     */
    self.startAt = async function (stepOrStepIdOrIndex) {
        await handleEvent(options.onStart);

        const step = getStep(stepOrStepIdOrIndex);

        setCurrentStep(step);
        tourStatus = TourStatus.ON;
        self.emit('started', step);

        if (options.useHotkeys) {
            setHotKeys();
        }

        return self.showStep(getCurrentStep());
    };

    /**
     * ends the tour
     *
     * @public
     */
    self.end = async function () {
        await handleEvent(options.onEnd);

        if (getCurrentStep()) {
            uiTourBackdrop.hide();
            await self.hideStep(getCurrentStep());
        }

        setCurrentStep(null);
        self.emit('ended');
        tourStatus = TourStatus.OFF;
        resumeWhenFound = null;

        if (options.useHotkeys) {
            unsetHotKeys();
        }
    };

    /**
     * pauses the tour
     *
     * @public
     */
    self.pause = async function () {
        await handleEvent(options.onPause);

        tourStatus = TourStatus.PAUSED;

        uiTourBackdrop.hide();
        await self.hideStep(getCurrentStep());

        self.emit('paused', getCurrentStep());
    };

    /**
     * resumes a paused tour or starts it
     *
     * @public
     */
    self.resume = async function () {
        await handleEvent(options.onResume);

        tourStatus = TourStatus.ON;
        self.emit('resumed', getCurrentStep());
        return self.showStep(getCurrentStep());
    };

    /**
     * move to next step
     *
     * @public
     * @returns {promise}
     */
    self.next = function () {
        return self.goTo('$next');
    };

    /**
     * move to previous step
     *
     * @public
     * @returns {promise}
     */
    self.prev = function () {
        return self.goTo('$prev');
    };

    /**
     * Jumps to the provided step, step ID, or step index
     *
     * @param {step | string | number} goTo Step object, step ID string, or step index to jump to
     * @returns {promise} Promise that resolves once the step is shown
     */
    self.goTo = async function (goTo) {
        var currentStep = getCurrentStep(),
            stepToShow = getStep(goTo),
            actionMap = {
                $prev: {
                    getStep: getPrevStep,
                    preEvent: 'onPrev',
                    navCheck: 'prevStep'
                },
                $next: {
                    getStep: getNextStep,
                    preEvent: 'onNext',
                    navCheck: 'nextStep'
                }
            };

        if (goTo === '$prev' || goTo === '$next') {
            //trigger either onNext or onPrev here
            //if next or previous requires a redirect, it will happen here
            //the tour will pause here until the next view loads and
            //the next/prev step is found
            await handleEvent(currentStep.config(actionMap[goTo].preEvent));
            await self.hideStep(currentStep);

            //if a redirect occurred during onNext or onPrev, getCurrentStep() !== currentStep
            //this will only be true if no redirect occurred, since the redirect sets current step
            if (!currentStep[actionMap[goTo].navCheck] || currentStep[actionMap[goTo].navCheck] !== getCurrentStep().stepId) {
                setCurrentStep(actionMap[goTo].getStep());
                self.emit('stepChanged', getCurrentStep());
            }

            //if the next/prev step does not have a backdrop, hide it
            if (getCurrentStep() && !getCurrentStep().config('backdrop')) {
                uiTourBackdrop.hide();
            }

            //if the next/prev step does not prevent scrolling, allow it
            if (getCurrentStep() && !getCurrentStep().config('preventScrolling')) {
                uiTourBackdrop.shouldPreventScrolling(false);
            }

            if (getCurrentStep()) {
                return self.showStep(getCurrentStep());
            }

            return self.end();
        }

        //if no step found
        if (!stepToShow) {
            throw 'No step.';
        }

        //take action
        await self.hideStep(getCurrentStep());

        //if the next/prev step does not have a backdrop, hide it
        if (getCurrentStep().config('backdrop') && !stepToShow.config('backdrop')) {
            uiTourBackdrop.hide();
        }

        //if the next/prev step does not prevent scrolling, allow it
        if (getCurrentStep().config('backdrop') && !stepToShow.config('preventScrolling')) {
            uiTourBackdrop.shouldPreventScrolling(false);
        }

        setCurrentStep(stepToShow);
        self.emit('stepChanged', getCurrentStep());
        return self.showStep(stepToShow);
    };

    /**
     * Tells the tour to pause until a specific step is added
     *
     * @public
     * @param waitForStep
     */
    self.waitFor = async function (waitForStep) {
        resumeWhenFound = function (step) {
            if (step.stepId === waitForStep) {
                setCurrentStep(stepList[stepList.indexOf(step)]);
                self.resume();
                resumeWhenFound = null;
            }
        };
        //must reject so that when used in a lifecycle hook the execution stops
        await self.pause();
        tourStatus = TourStatus.WAITING;
        return $q.reject();
    };

    /**
     * Creates a step from a configuration object
     *
     * @param {{}} options - Step options, all are static
     */
    self.createStep = function (options) {
        const step = TourStepService.createStep(options, self);

        if (self.initialized) {
            self.addStep(step);
        } else {
            self.once('initialized', function () {
                self.addStep(step);
            });
        }

        return step;
    };

    /**
     * returns a copy of the current step (copied to avoid breaking internal functions)
     *
     * @returns {step}
     */
    self.getCurrentStep = function () {
        return getCurrentStep();
    };

    /**
     * Forces the popover and backdrop to reposition
     */
    self.reposition = function () {
        if (getCurrentStep()) {
            getCurrentStep().reposition();
            uiTourBackdrop.reposition();
        }
    };

    /**
     * @typedef number TourStatus
     */

    /**
     * Returns the current status of the tour
     * @returns {TourStatus}
     */
    self.getStatus = function () {
        return tourStatus;
    };
    /**
     * @enum TourStatus
     */
    self.Status = TourStatus;

    //------------------ end Public API ------------------

    //some debugging functions
    //all are private, unsafe, subject to change
    //strongly not recommended for production code

    self._getSteps = function () {
        return stepList;
    };
    self._getCurrentStep = getCurrentStep;
    self._setCurrentStep = setCurrentStep;

    return self;
}