MDL-52777 tool_usertours: Add the new User Tours plugin
authorAndrew Nicols <andrew@nicols.co.uk>
Sat, 1 Oct 2016 13:24:29 +0000 (21:24 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 19 Oct 2016 01:48:03 +0000 (09:48 +0800)
80 files changed:
.eslintignore
.stylelintignore
admin/tool/usertours/amd/build/managesteps.min.js [new file with mode: 0644]
admin/tool/usertours/amd/build/managetours.min.js [new file with mode: 0644]
admin/tool/usertours/amd/build/popper.min.js [new file with mode: 0644]
admin/tool/usertours/amd/build/tour.min.js [new file with mode: 0644]
admin/tool/usertours/amd/build/usertours.min.js [new file with mode: 0644]
admin/tool/usertours/amd/readme_moodle.txt [new file with mode: 0644]
admin/tool/usertours/amd/src/managesteps.js [new file with mode: 0644]
admin/tool/usertours/amd/src/managetours.js [new file with mode: 0644]
admin/tool/usertours/amd/src/popper.js [new file with mode: 0644]
admin/tool/usertours/amd/src/tour.js [new file with mode: 0644]
admin/tool/usertours/amd/src/usertours.js [new file with mode: 0644]
admin/tool/usertours/classes/configuration.php [new file with mode: 0644]
admin/tool/usertours/classes/event/step_shown.php [new file with mode: 0644]
admin/tool/usertours/classes/event/tour_ended.php [new file with mode: 0644]
admin/tool/usertours/classes/event/tour_reset.php [new file with mode: 0644]
admin/tool/usertours/classes/event/tour_started.php [new file with mode: 0644]
admin/tool/usertours/classes/external/tour.php [new file with mode: 0644]
admin/tool/usertours/classes/helper.php [new file with mode: 0644]
admin/tool/usertours/classes/local/filter/base.php [new file with mode: 0644]
admin/tool/usertours/classes/local/filter/role.php [new file with mode: 0644]
admin/tool/usertours/classes/local/filter/theme.php [new file with mode: 0644]
admin/tool/usertours/classes/local/forms/editstep.php [new file with mode: 0644]
admin/tool/usertours/classes/local/forms/edittour.php [new file with mode: 0644]
admin/tool/usertours/classes/local/forms/importtour.php [new file with mode: 0644]
admin/tool/usertours/classes/local/table/step_list.php [new file with mode: 0644]
admin/tool/usertours/classes/local/table/tour_list.php [new file with mode: 0644]
admin/tool/usertours/classes/local/target/base.php [new file with mode: 0644]
admin/tool/usertours/classes/local/target/block.php [new file with mode: 0644]
admin/tool/usertours/classes/local/target/selector.php [new file with mode: 0644]
admin/tool/usertours/classes/local/target/unattached.php [new file with mode: 0644]
admin/tool/usertours/classes/manager.php [new file with mode: 0644]
admin/tool/usertours/classes/output/renderer.php [new file with mode: 0644]
admin/tool/usertours/classes/output/step.php [new file with mode: 0644]
admin/tool/usertours/classes/output/tour.php [new file with mode: 0644]
admin/tool/usertours/classes/step.php [new file with mode: 0644]
admin/tool/usertours/classes/target.php [new file with mode: 0644]
admin/tool/usertours/classes/tour.php [new file with mode: 0644]
admin/tool/usertours/configure.php [new file with mode: 0644]
admin/tool/usertours/db/access.php [new file with mode: 0644]
admin/tool/usertours/db/install.php [new file with mode: 0644]
admin/tool/usertours/db/install.xml [new file with mode: 0644]
admin/tool/usertours/db/services.php [new file with mode: 0644]
admin/tool/usertours/lang/en/tool_usertours.php [new file with mode: 0644]
admin/tool/usertours/lib.php [new file with mode: 0644]
admin/tool/usertours/pix/b/tour-import.png [new file with mode: 0644]
admin/tool/usertours/pix/b/tour-new.png [new file with mode: 0644]
admin/tool/usertours/pix/b/tour-shared.png [new file with mode: 0644]
admin/tool/usertours/pix/i/reload.png [new file with mode: 0644]
admin/tool/usertours/pix/i/reload.svg [new file with mode: 0644]
admin/tool/usertours/pix/i/sprite-green.png [new file with mode: 0644]
admin/tool/usertours/pix/i/sprite-orange.png [new file with mode: 0644]
admin/tool/usertours/pix/t/export.png [new file with mode: 0644]
admin/tool/usertours/pix/t/export.svg [new file with mode: 0644]
admin/tool/usertours/pix/t/filler.svg [new file with mode: 0644]
admin/tool/usertours/settings.php [new file with mode: 0644]
admin/tool/usertours/styles.css [new file with mode: 0644]
admin/tool/usertours/styles_bootstrapbase.css [new file with mode: 0644]
admin/tool/usertours/templates/selecttarget.mustache [new file with mode: 0644]
admin/tool/usertours/templates/tourstep.mustache [new file with mode: 0644]
admin/tool/usertours/tests/behat/behat_tool_usertours.php [new file with mode: 0644]
admin/tool/usertours/tests/behat/create_tour.feature [new file with mode: 0644]
admin/tool/usertours/tests/behat/tour_filter.feature [new file with mode: 0644]
admin/tool/usertours/tests/manager_test.php [new file with mode: 0644]
admin/tool/usertours/tests/role_filter_test.php [new file with mode: 0644]
admin/tool/usertours/tests/step_output_test.php [new file with mode: 0644]
admin/tool/usertours/tests/step_test.php [new file with mode: 0644]
admin/tool/usertours/tests/theme_filter_test.php [new file with mode: 0644]
admin/tool/usertours/tests/tour_test.php [new file with mode: 0644]
admin/tool/usertours/thirdpartylibs.xml [new file with mode: 0644]
admin/tool/usertours/version.php [new file with mode: 0644]
lib/classes/plugin_manager.php
theme/boost/scss/moodle.scss
theme/boost/scss/moodle/modules.scss
theme/boost/scss/moodle/tool_usertours.scss [new file with mode: 0644]
theme/boost/templates/tool_usertours/tourstep.mustache [new file with mode: 0644]
theme/bootstrapbase/less/moodle.less
theme/bootstrapbase/less/moodle/tool_usertours.less [new file with mode: 0644]
theme/bootstrapbase/style/moodle.css

index 73b2b4a..ba9d529 100644 (file)
@@ -3,6 +3,8 @@
 */**/build/
 node_modules/
 vendor/
+admin/tool/usertours/amd/src/tour.js
+admin/tool/usertours/amd/src/popper.js
 auth/cas/CAS/
 auth/fc/fcFPP.php
 enrol/lti/ims-blti/
index b9e8b2c..4959447 100644 (file)
@@ -2,6 +2,8 @@
 theme/bootstrapbase/style/
 node_modules/
 vendor/
+admin/tool/usertours/amd/src/tour.js
+admin/tool/usertours/amd/src/popper.js
 auth/cas/CAS/
 auth/fc/fcFPP.php
 enrol/lti/ims-blti/
diff --git a/admin/tool/usertours/amd/build/managesteps.min.js b/admin/tool/usertours/amd/build/managesteps.min.js
new file mode 100644 (file)
index 0000000..886c250
Binary files /dev/null and b/admin/tool/usertours/amd/build/managesteps.min.js differ
diff --git a/admin/tool/usertours/amd/build/managetours.min.js b/admin/tool/usertours/amd/build/managetours.min.js
new file mode 100644 (file)
index 0000000..9c9e2e6
Binary files /dev/null and b/admin/tool/usertours/amd/build/managetours.min.js differ
diff --git a/admin/tool/usertours/amd/build/popper.min.js b/admin/tool/usertours/amd/build/popper.min.js
new file mode 100644 (file)
index 0000000..ac29055
Binary files /dev/null and b/admin/tool/usertours/amd/build/popper.min.js differ
diff --git a/admin/tool/usertours/amd/build/tour.min.js b/admin/tool/usertours/amd/build/tour.min.js
new file mode 100644 (file)
index 0000000..2a58fee
Binary files /dev/null and b/admin/tool/usertours/amd/build/tour.min.js differ
diff --git a/admin/tool/usertours/amd/build/usertours.min.js b/admin/tool/usertours/amd/build/usertours.min.js
new file mode 100644 (file)
index 0000000..417008a
Binary files /dev/null and b/admin/tool/usertours/amd/build/usertours.min.js differ
diff --git a/admin/tool/usertours/amd/readme_moodle.txt b/admin/tool/usertours/amd/readme_moodle.txt
new file mode 100644 (file)
index 0000000..d2db94e
--- /dev/null
@@ -0,0 +1,17 @@
+Description of External library imports into Moodle
+
+Flexitour Instructions
+----------------------
+1. Clone https://github.com/andrewnicols/flexitour into an unrelated directory
+2. Copy /build/tour.js to amd/src/tour.js
+3. Open the amd/src/tour.js file and find the AMD module define.
+4. Change the "popper" inclusion to "./popper"
+5. Update thirdpartylibs.xml
+6. Run `grunt amd`
+
+Popper.js Instructions
+----------------------
+1. Clone https://github.com/FezVrasta/popper.js into an unrelated directory
+2. Copy /build/popper.js to amd/src/popper.js
+3. Update thirdpartylibs.xml
+4. Run `grunt amd`
diff --git a/admin/tool/usertours/amd/src/managesteps.js b/admin/tool/usertours/amd/src/managesteps.js
new file mode 100644 (file)
index 0000000..ba52019
--- /dev/null
@@ -0,0 +1,64 @@
+/**
+ * Step management code.
+ *
+ * @module     tool_usertours/managesteps
+ * @class      managesteps
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ */
+define(
+['jquery', 'core/str', 'core/notification'],
+function($, str, notification) {
+    var manager = {
+        /**
+         * Confirm removal of the specified step.
+         *
+         * @method  removeStep
+         * @param   {EventFacade}   e   The EventFacade
+         */
+        removeStep: function(e) {
+            e.preventDefault();
+            str.get_strings([
+                {
+                    key:        'confirmstepremovaltitle',
+                    component:  'tool_usertours'
+                },
+                {
+                    key:        'confirmstepremovalquestion',
+                    component:  'tool_usertours'
+                },
+                {
+                    key:        'yes',
+                    component:  'moodle'
+                },
+                {
+                    key:        'no',
+                    component:  'moodle'
+                }
+            ]).done(function(s) {
+                notification.confirm(s[0], s[1], s[2], s[3], $.proxy(function() {
+                    window.location = $(this).attr('href');
+                }, e.currentTarget));
+            });
+        },
+
+        /**
+         * Setup the step management UI.
+         *
+         * @method          setup
+         */
+        setup: function() {
+
+            $('body').delegate('[data-action="delete"]', 'click', manager.removeStep);
+        }
+    };
+
+    return /** @alias module:tool_usertours/managesteps */ {
+        /**
+         * Setup the step management UI.
+         *
+         * @method          setup
+         */
+        setup: manager.setup
+    };
+});
diff --git a/admin/tool/usertours/amd/src/managetours.js b/admin/tool/usertours/amd/src/managetours.js
new file mode 100644 (file)
index 0000000..f5c601a
--- /dev/null
@@ -0,0 +1,64 @@
+/**
+ * Tour management code.
+ *
+ * @module     tool_usertours/managetours
+ * @class      managetours
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ */
+define(
+['jquery', 'core/ajax', 'core/str', 'core/notification'],
+function($, ajax, str, notification) {
+    var manager = {
+        /**
+         * Confirm removal of the specified tour.
+         *
+         * @method  removeTour
+         * @param   {EventFacade}   e   The EventFacade
+         */
+        removeTour: function(e) {
+            e.preventDefault();
+
+            str.get_strings([
+                {
+                    key:        'confirmtourremovaltitle',
+                    component:  'tool_usertours'
+                },
+                {
+                    key:        'confirmtourremovalquestion',
+                    component:  'tool_usertours'
+                },
+                {
+                    key:        'yes',
+                    component:  'moodle'
+                },
+                {
+                    key:        'no',
+                    component:  'moodle'
+                }
+            ]).done(function(s) {
+                notification.confirm(s[0], s[1], s[2], s[3], $.proxy(function() {
+                    window.location = $(this).attr('href');
+                }, e.currentTarget));
+            });
+        },
+
+        /**
+         * Setup the tour management UI.
+         *
+         * @method          setup
+         */
+        setup: function() {
+            $('body').delegate('[data-action="delete"]', 'click', manager.removeTour);
+        }
+    };
+
+    return /** @alias module:tool_usertours/managetours */ {
+        /**
+         * Setup the tour management UI.
+         *
+         * @method          setup
+         */
+        setup: manager.setup
+    };
+});
diff --git a/admin/tool/usertours/amd/src/popper.js b/admin/tool/usertours/amd/src/popper.js
new file mode 100644 (file)
index 0000000..16119ab
--- /dev/null
@@ -0,0 +1,1325 @@
+/**
+ * @fileOverview Kickass library to create and place poppers near their reference elements.
+ * @version 0.6.4
+ * @license
+ * Copyright (c) 2016 Federico Zivolo and contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+//
+// Cross module loader
+// Supported: Node, AMD, Browser globals
+//
+;(function (root, factory) {
+    if (typeof define === 'function' && define.amd) {
+        // AMD. Register as an anonymous module.
+        define(factory);
+    } else if (typeof module === 'object' && module.exports) {
+        // Node. Does not work with strict CommonJS, but
+        // only CommonJS-like environments that support module.exports,
+        // like Node.
+        module.exports = factory();
+    } else {
+        // Browser globals (root is window)
+        root.Popper = factory();
+    }
+}(this, function () {
+
+    'use strict';
+
+    var root = window;
+
+    // default options
+    var DEFAULTS = {
+        // placement of the popper
+        placement: 'bottom',
+
+        gpuAcceleration: true,
+
+        // shift popper from its origin by the given amount of pixels (can be negative)
+        offset: 0,
+
+        // the element which will act as boundary of the popper
+        boundariesElement: 'viewport',
+
+        // amount of pixel used to define a minimum distance between the boundaries and the popper
+        boundariesPadding: 5,
+
+        // popper will try to prevent overflow following this order,
+        // by default, then, it could overflow on the left and on top of the boundariesElement
+        preventOverflowOrder: ['left', 'right', 'top', 'bottom'],
+
+        // the behavior used by flip to change the placement of the popper
+        flipBehavior: 'flip',
+
+        arrowElement: '[x-arrow]',
+
+        // list of functions used to modify the offsets before they are applied to the popper
+        modifiers: [ 'shift', 'offset', 'preventOverflow', 'keepTogether', 'arrow', 'flip', 'applyStyle'],
+
+        modifiersIgnored: [],
+    };
+
+    /**
+     * Create a new Popper.js instance
+     * @constructor Popper
+     * @param {HTMLElement} reference - The reference element used to position the popper
+     * @param {HTMLElement|Object} popper
+     *      The HTML element used as popper, or a configuration used to generate the popper.
+     * @param {String} [popper.tagName='div'] The tag name of the generated popper.
+     * @param {Array} [popper.classNames=['popper']] Array of classes to apply to the generated popper.
+     * @param {Array} [popper.attributes] Array of attributes to apply, specify `attr:value` to assign a value to it.
+     * @param {HTMLElement|String} [popper.parent=window.document.body] The parent element, given as HTMLElement or as query string.
+     * @param {String} [popper.content=''] The content of the popper, it can be text, html, or node; if it is not text, set `contentType` to `html` or `node`.
+     * @param {String} [popper.contentType='text'] If `html`, the `content` will be parsed as HTML. If `node`, it will be appended as-is.
+     * @param {String} [popper.arrowTagName='div'] Same as `popper.tagName` but for the arrow element.
+     * @param {Array} [popper.arrowClassNames='popper__arrow'] Same as `popper.classNames` but for the arrow element.
+     * @param {String} [popper.arrowAttributes=['x-arrow']] Same as `popper.attributes` but for the arrow element.
+     * @param {Object} options
+     * @param {String} [options.placement=bottom]
+     *      Placement of the popper accepted values: `top(-start, -end), right(-start, -end), bottom(-start, -right),
+     *      left(-start, -end)`
+     *
+     * @param {HTMLElement|String} [options.arrowElement='[x-arrow]']
+     *      The DOM Node used as arrow for the popper, or a CSS selector used to get the DOM node. It must be child of
+     *      its parent Popper. Popper.js will apply to the given element the style required to align the arrow with its
+     *      reference element.
+     *      By default, it will look for a child node of the popper with the `x-arrow` attribute.
+     *
+     * @param {Boolean} [options.gpuAcceleration=true]
+     *      When this property is set to true, the popper position will be applied using CSS3 translate3d, allowing the
+     *      browser to use the GPU to accelerate the rendering.
+     *      If set to false, the popper will be placed using `top` and `left` properties, not using the GPU.
+     *
+     * @param {Number} [options.offset=0]
+     *      Amount of pixels the popper will be shifted (can be negative).
+     *
+     * @param {String|Element} [options.boundariesElement='viewport']
+     *      The element which will define the boundaries of the popper position, the popper will never be placed outside
+     *      of the defined boundaries (except if `keepTogether` is enabled)
+     *
+     * @param {Number} [options.boundariesPadding=5]
+     *      Additional padding for the boundaries
+     *
+     * @param {Array} [options.preventOverflowOrder=['left', 'right', 'top', 'bottom']]
+     *      Order used when Popper.js tries to avoid overflows from the boundaries, they will be checked in order,
+     *      this means that the last ones will never overflow
+     *
+     * @param {String|Array} [options.flipBehavior='flip']
+     *      The behavior used by the `flip` modifier to change the placement of the popper when the latter is trying to
+     *      overlap its reference element. Defining `flip` as value, the placement will be flipped on
+     *      its axis (`right - left`, `top - bottom`).
+     *      You can even pass an array of placements (eg: `['right', 'left', 'top']` ) to manually specify
+     *      how alter the placement when a flip is needed. (eg. in the above example, it would first flip from right to left,
+     *      then, if even in its new placement, the popper is overlapping its reference element, it will be moved to top)
+     *
+     * @param {Array} [options.modifiers=[ 'shift', 'offset', 'preventOverflow', 'keepTogether', 'arrow', 'flip', 'applyStyle']]
+     *      List of functions used to modify the data before they are applied to the popper, add your custom functions
+     *      to this array to edit the offsets and placement.
+     *      The function should reflect the @params and @returns of preventOverflow
+     *
+     * @param {Array} [options.modifiersIgnored=[]]
+     *      Put here any built-in modifier name you want to exclude from the modifiers list
+     *      The function should reflect the @params and @returns of preventOverflow
+     *
+     * @param {Boolean} [options.removeOnDestroy=false]
+     *      Set to true if you want to automatically remove the popper when you call the `destroy` method.
+     */
+    function Popper(reference, popper, options) {
+        this._reference = reference.jquery ? reference[0] : reference;
+        this.state = { onCreateCalled: false };
+
+        // if the popper variable is a configuration object, parse it to generate an HTMLElement
+        // generate a default popper if is not defined
+        var isNotDefined = typeof popper === 'undefined' || popper === null;
+        var isConfig = popper && Object.prototype.toString.call(popper) === '[object Object]';
+        if (isNotDefined || isConfig) {
+            this._popper = this.parse(isConfig ? popper : {});
+        }
+        // otherwise, use the given HTMLElement as popper
+        else {
+            this._popper = popper.jquery ? popper[0] : popper;
+        }
+
+        // with {} we create a new object with the options inside it
+        this._options = Object.assign({}, DEFAULTS, options);
+
+        // refactoring modifiers' list
+        this._options.modifiers = this._options.modifiers.map(function(modifier){
+            // remove ignored modifiers
+            if (this._options.modifiersIgnored.indexOf(modifier) !== -1) return;
+
+            // set the x-placement attribute before everything else because it could be used to add margins to the popper
+            // margins needs to be calculated to get the correct popper offsets
+            if (modifier === 'applyStyle') {
+                this._popper.setAttribute('x-placement', this._options.placement);
+            }
+
+            // return predefined modifier identified by string or keep the custom one
+            return this.modifiers[modifier] || modifier;
+        }.bind(this));
+
+        // make sure to apply the popper position before any computation
+        this.state.position = this._getPosition(this._popper, this._reference);
+        setStyle(this._popper, { position: this.state.position});
+
+        // determine how we should set the origin of offsets
+        this.state.isParentTransformed = this._getIsParentTransformed(this._popper);
+
+        // fire the first update to position the popper in the right place
+        this.update();
+
+        // setup event listeners, they will take care of update the position in specific situations
+        this._setupEventListeners();
+        return this;
+    }
+
+
+    //
+    // Methods
+    //
+    /**
+     * Destroy the popper
+     * @method
+     * @memberof Popper
+     */
+    Popper.prototype.destroy = function() {
+        this._popper.removeAttribute('x-placement');
+        this._popper.style.left = '';
+        this._popper.style.position = '';
+        this._popper.style.top = '';
+        this._popper.style[getSupportedPropertyName('transform')] = '';
+        this._removeEventListeners();
+
+        // remove the popper if user explicity asked for the deletion on destroy
+        if (this._options.removeOnDestroy) {
+            this._popper.parentNode.removeChild(this._popper);
+        }
+        return this;
+    };
+
+    /**
+     * Updates the position of the popper, computing the new offsets and applying the new style
+     * @method
+     * @memberof Popper
+     */
+    Popper.prototype.update = function() {
+        var data = { instance: this, styles: {} };
+
+        // make sure to apply the popper position before any computation
+        this.state.position = this._getPosition(this._popper, this._reference);
+        setStyle(this._popper, { position: this.state.position});
+
+        // to avoid useless computations we throttle the popper position refresh to 60fps
+        root.requestAnimationFrame(function() {
+            var now = root.performance.now();
+            if(now - this.state.lastFrame <= 16) {
+                // this update fired to early! drop it
+                return;
+            }
+            this.state.lastFrame = now;
+
+            // store placement inside the data object, modifiers will be able to edit `placement` if needed
+            // and refer to _originalPlacement to know the original value
+            data.placement = this._options.placement;
+            data._originalPlacement = this._options.placement;
+
+            // compute the popper and trigger offsets and put them inside data.offsets
+            data.offsets = this._getOffsets(this._popper, this._reference, data.placement);
+
+            // get boundaries
+            data.boundaries = this._getBoundaries(data, this._options.boundariesPadding, this._options.boundariesElement);
+
+            data = this.runModifiers(data, this._options.modifiers);
+
+            if (!isFunction(this.state.createCalback)) {
+                this.state.onCreateCalled = true;
+            }
+            if (!this.state.onCreateCalled) {
+                this.state.onCreateCalled = true;
+                if (isFunction(this.state.createCalback)) {
+                    this.state.createCalback(this);
+                }
+            } else if (isFunction(this.state.updateCallback)) {
+                this.state.updateCallback(data);
+            }
+        }.bind(this));
+    };
+
+    /**
+     * If a function is passed, it will be executed after the initialization of popper with as first argument the Popper instance.
+     * @method
+     * @memberof Popper
+     * @param {Function} callback
+     */
+    Popper.prototype.onCreate = function(callback) {
+        // the createCallbacks return as first argument the popper instance
+        this.state.createCalback = callback;
+        return this;
+    };
+
+    /**
+     * If a function is passed, it will be executed after each update of popper with as first argument the set of coordinates and informations
+     * used to style popper and its arrow.
+     * NOTE: it doesn't get fired on the first call of the `Popper.update()` method inside the `Popper` constructor!
+     * @method
+     * @memberof Popper
+     * @param {Function} callback
+     */
+    Popper.prototype.onUpdate = function(callback) {
+        this.state.updateCallback = callback;
+        return this;
+    };
+
+    /**
+     * Helper used to generate poppers from a configuration file
+     * @method
+     * @memberof Popper
+     * @param config {Object} configuration
+     * @returns {HTMLElement} popper
+     */
+    Popper.prototype.parse = function(config) {
+        var defaultConfig = {
+            tagName: 'div',
+            classNames: [ 'popper' ],
+            attributes: [],
+            parent: root.document.body,
+            content: '',
+            contentType: 'text',
+            arrowTagName: 'div',
+            arrowClassNames: [ 'popper__arrow' ],
+            arrowAttributes: [ 'x-arrow']
+        };
+        config = Object.assign({}, defaultConfig, config);
+
+        var d = root.document;
+
+        var popper = d.createElement(config.tagName);
+        addClassNames(popper, config.classNames);
+        addAttributes(popper, config.attributes);
+        if (config.contentType === 'node') {
+            popper.appendChild(config.content.jquery ? config.content[0] : config.content);
+        }else if (config.contentType === 'html') {
+            popper.innerHTML = config.content;
+        } else {
+            popper.textContent = config.content;
+        }
+
+        if (config.arrowTagName) {
+            var arrow = d.createElement(config.arrowTagName);
+            addClassNames(arrow, config.arrowClassNames);
+            addAttributes(arrow, config.arrowAttributes);
+            popper.appendChild(arrow);
+        }
+
+        var parent = config.parent.jquery ? config.parent[0] : config.parent;
+
+        // if the given parent is a string, use it to match an element
+        // if more than one element is matched, the first one will be used as parent
+        // if no elements are matched, the script will throw an error
+        if (typeof parent === 'string') {
+            parent = d.querySelectorAll(config.parent);
+            if (parent.length > 1) {
+                console.warn('WARNING: the given `parent` query(' + config.parent + ') matched more than one element, the first one will be used');
+            }
+            if (parent.length === 0) {
+                throw 'ERROR: the given `parent` doesn\'t exists!';
+            }
+            parent = parent[0];
+        }
+        // if the given parent is a DOM nodes list or an array of nodes with more than one element,
+        // the first one will be used as parent
+        if (parent.length > 1 && parent instanceof Element === false) {
+            console.warn('WARNING: you have passed as parent a list of elements, the first one will be used');
+            parent = parent[0];
+        }
+
+        // append the generated popper to its parent
+        parent.appendChild(popper);
+
+        return popper;
+
+        /**
+         * Adds class names to the given element
+         * @function
+         * @ignore
+         * @param {HTMLElement} target
+         * @param {Array} classes
+         */
+        function addClassNames(element, classNames) {
+            classNames.forEach(function(className) {
+                element.classList.add(className);
+            });
+        }
+
+        /**
+         * Adds attributes to the given element
+         * @function
+         * @ignore
+         * @param {HTMLElement} target
+         * @param {Array} attributes
+         * @example
+         * addAttributes(element, [ 'data-info:foobar' ]);
+         */
+        function addAttributes(element, attributes) {
+            attributes.forEach(function(attribute) {
+                element.setAttribute(attribute.split(':')[0], attribute.split(':')[1] || '');
+            });
+        }
+
+    };
+
+    /**
+     * Helper used to get the position which will be applied to the popper
+     * @method
+     * @memberof Popper
+     * @param config {HTMLElement} popper element
+     * @returns {HTMLElement} reference element
+     */
+    Popper.prototype._getPosition = function(popper, reference) {
+        var container = getOffsetParent(reference);
+
+        // Decide if the popper will be fixed
+        // If the reference element is inside a fixed context, the popper will be fixed as well to allow them to scroll together
+        var isParentFixed = isFixed(container);
+        return isParentFixed ? 'fixed' : 'absolute';
+    };
+
+    /**
+     * Helper used to determine if the popper's parent is transformed.
+     * @param  {[type]} popper [description]
+     * @return {[type]}        [description]
+     */
+    Popper.prototype._getIsParentTransformed = function(popper) {
+      return isTransformed(popper.parentNode);
+    };
+
+    /**
+     * Get offsets to the popper
+     * @method
+     * @memberof Popper
+     * @access private
+     * @param {Element} popper - the popper element
+     * @param {Element} reference - the reference element (the popper will be relative to this)
+     * @returns {Object} An object containing the offsets which will be applied to the popper
+     */
+    Popper.prototype._getOffsets = function(popper, reference, placement) {
+        placement = placement.split('-')[0];
+        var popperOffsets = {};
+
+        popperOffsets.position = this.state.position;
+        var isParentFixed = popperOffsets.position === 'fixed';
+
+        var isParentTransformed = this.state.isParentTransformed;
+
+        //
+        // Get reference element position
+        //
+        var offsetParent = (isParentFixed && isParentTransformed) ? getOffsetParent(reference) : getOffsetParent(popper);
+        var referenceOffsets = getOffsetRectRelativeToCustomParent(reference, offsetParent, isParentFixed, isParentTransformed);
+
+        //
+        // Get popper sizes
+        //
+        var popperRect = getOuterSizes(popper);
+
+        //
+        // Compute offsets of popper
+        //
+
+        // depending by the popper placement we have to compute its offsets slightly differently
+        if (['right', 'left'].indexOf(placement) !== -1) {
+            popperOffsets.top = referenceOffsets.top + referenceOffsets.height / 2 - popperRect.height / 2;
+            if (placement === 'left') {
+                popperOffsets.left = referenceOffsets.left - popperRect.width;
+            } else {
+                popperOffsets.left = referenceOffsets.right;
+            }
+        } else {
+            popperOffsets.left = referenceOffsets.left + referenceOffsets.width / 2 - popperRect.width / 2;
+            if (placement === 'top') {
+                popperOffsets.top = referenceOffsets.top - popperRect.height;
+            } else {
+                popperOffsets.top = referenceOffsets.bottom;
+            }
+        }
+
+        // Add width and height to our offsets object
+        popperOffsets.width   = popperRect.width;
+        popperOffsets.height  = popperRect.height;
+
+
+        return {
+            popper: popperOffsets,
+            reference: referenceOffsets
+        };
+    };
+
+
+    /**
+     * Setup needed event listeners used to update the popper position
+     * @method
+     * @memberof Popper
+     * @access private
+     */
+    Popper.prototype._setupEventListeners = function() {
+        // NOTE: 1 DOM access here
+        this.state.updateBound = this.update.bind(this);
+        root.addEventListener('resize', this.state.updateBound);
+        // if the boundariesElement is window we don't need to listen for the scroll event
+        if (this._options.boundariesElement !== 'window') {
+            var target = getScrollParent(this._reference);
+            // here it could be both `body` or `documentElement` thanks to Firefox, we then check both
+            if (target === root.document.body || target === root.document.documentElement) {
+                target = root;
+            }
+            target.addEventListener('scroll', this.state.updateBound);
+        }
+    };
+
+    /**
+     * Remove event listeners used to update the popper position
+     * @method
+     * @memberof Popper
+     * @access private
+     */
+    Popper.prototype._removeEventListeners = function() {
+        // NOTE: 1 DOM access here
+        root.removeEventListener('resize', this.state.updateBound);
+        if (this._options.boundariesElement !== 'window') {
+            var target = getScrollParent(this._reference);
+            // here it could be both `body` or `documentElement` thanks to Firefox, we then check both
+            if (target === root.document.body || target === root.document.documentElement) {
+                target = root;
+            }
+            target.removeEventListener('scroll', this.state.updateBound);
+        }
+        this.state.updateBound = null;
+    };
+
+    /**
+     * Computed the boundaries limits and return them
+     * @method
+     * @memberof Popper
+     * @access private
+     * @param {Object} data - Object containing the property "offsets" generated by `_getOffsets`
+     * @param {Number} padding - Boundaries padding
+     * @param {Element} boundariesElement - Element used to define the boundaries
+     * @returns {Object} Coordinates of the boundaries
+     */
+    Popper.prototype._getBoundaries = function(data, padding, boundariesElement) {
+        // NOTE: 1 DOM access here
+        var boundaries = {};
+        var width, height;
+        if (boundariesElement === 'window') {
+            var body = root.document.body,
+                html = root.document.documentElement;
+
+            height = Math.max( body.scrollHeight, body.offsetHeight, html.clientHeight, html.scrollHeight, html.offsetHeight );
+            width = Math.max( body.scrollWidth, body.offsetWidth, html.clientWidth, html.scrollWidth, html.offsetWidth );
+
+            boundaries = {
+                top: 0,
+                right: width,
+                bottom: height,
+                left: 0
+            };
+        } else if (boundariesElement === 'viewport') {
+            var offsetParent = getOffsetParent(this._popper);
+            var scrollParent = getScrollParent(this._popper);
+            var offsetParentRect = getOffsetRect(offsetParent);
+
+            // if the popper is fixed we don't have to substract scrolling from the boundaries
+            var scrollTop = data.offsets.popper.position === 'fixed' ? 0 : scrollParent.scrollTop;
+            var scrollLeft = data.offsets.popper.position === 'fixed' ? 0 : scrollParent.scrollLeft;
+
+            boundaries = {
+                top: 0 - (offsetParentRect.top - scrollTop),
+                right: root.document.documentElement.clientWidth - (offsetParentRect.left - scrollLeft),
+                bottom: root.document.documentElement.clientHeight - (offsetParentRect.top - scrollTop),
+                left: 0 - (offsetParentRect.left - scrollLeft)
+            };
+        } else {
+            if (getOffsetParent(this._popper) === boundariesElement) {
+                boundaries = {
+                    top: 0,
+                    left: 0,
+                    right: boundariesElement.clientWidth,
+                    bottom: boundariesElement.clientHeight
+                };
+            } else {
+                boundaries = getOffsetRect(boundariesElement);
+            }
+        }
+        boundaries.left += padding;
+        boundaries.right -= padding;
+        boundaries.top = boundaries.top + padding;
+        boundaries.bottom = boundaries.bottom - padding;
+        return boundaries;
+    };
+
+
+    /**
+     * Loop trough the list of modifiers and run them in order, each of them will then edit the data object
+     * @method
+     * @memberof Popper
+     * @access public
+     * @param {Object} data
+     * @param {Array} modifiers
+     * @param {Function} ends
+     */
+    Popper.prototype.runModifiers = function(data, modifiers, ends) {
+        var modifiersToRun = modifiers.slice();
+        if (ends !== undefined) {
+            modifiersToRun = this._options.modifiers.slice(0, getArrayKeyIndex(this._options.modifiers, ends));
+        }
+
+        modifiersToRun.forEach(function(modifier) {
+            if (isFunction(modifier)) {
+                data = modifier.call(this, data);
+            }
+        }.bind(this));
+
+        return data;
+    };
+
+    /**
+     * Helper used to know if the given modifier depends from another one.
+     * @method
+     * @memberof Popper
+     * @returns {Boolean}
+     */
+
+    Popper.prototype.isModifierRequired = function(requesting, requested) {
+        var index = getArrayKeyIndex(this._options.modifiers, requesting);
+        return !!this._options.modifiers.slice(0, index).filter(function(modifier) {
+            return modifier === requested;
+        }).length;
+    };
+
+    //
+    // Modifiers
+    //
+
+    /**
+     * Modifiers list
+     * @namespace Popper.modifiers
+     * @memberof Popper
+     * @type {Object}
+     */
+    Popper.prototype.modifiers = {};
+
+    /**
+     * Apply the computed styles to the popper element
+     * @method
+     * @memberof Popper.modifiers
+     * @argument {Object} data - The data object generated by `update` method
+     * @returns {Object} The same data object
+     */
+    Popper.prototype.modifiers.applyStyle = function(data) {
+        // apply the final offsets to the popper
+        // NOTE: 1 DOM access here
+        var styles = {
+            position: data.offsets.popper.position
+        };
+
+        // round top and left to avoid blurry text
+        var left = Math.round(data.offsets.popper.left);
+        var top = Math.round(data.offsets.popper.top);
+
+        // if gpuAcceleration is set to true and transform is supported, we use `translate3d` to apply the position to the popper
+        // we automatically use the supported prefixed version if needed
+        var prefixedProperty;
+        if (this._options.gpuAcceleration && (prefixedProperty = getSupportedPropertyName('transform'))) {
+            styles[prefixedProperty] = 'translate3d(' + left + 'px, ' + top + 'px, 0)';
+            styles.top = 0;
+            styles.left = 0;
+        }
+        // othwerise, we use the standard `left` and `top` properties
+        else {
+            styles.left =left;
+            styles.top = top;
+        }
+
+        // any property present in `data.styles` will be applied to the popper,
+        // in this way we can make the 3rd party modifiers add custom styles to it
+        // Be aware, modifiers could override the properties defined in the previous
+        // lines of this modifier!
+        Object.assign(styles, data.styles);
+
+        setStyle(this._popper, styles);
+
+        // set an attribute which will be useful to style the tooltip (use it to properly position its arrow)
+        // NOTE: 1 DOM access here
+        this._popper.setAttribute('x-placement', data.placement);
+
+        // if the arrow style has been computed, apply the arrow style
+        if (data.offsets.arrow) {
+            setStyle(data.arrowElement, data.offsets.arrow);
+        }
+
+        // return the data object to allow chaining of other modifiers
+        return data;
+    };
+
+    /**
+     * Modifier used to shift the popper on the start or end of its reference element side
+     * @method
+     * @memberof Popper.modifiers
+     * @argument {Object} data - The data object generated by `update` method
+     * @returns {Object} The data object, properly modified
+     */
+    Popper.prototype.modifiers.shift = function(data) {
+        var placement = data.placement;
+        var basePlacement = placement.split('-')[0];
+        var shiftVariation = placement.split('-')[1];
+
+        // if shift shiftVariation is specified, run the modifier
+        if (shiftVariation) {
+            var reference = data.offsets.reference;
+            var popper = getPopperClientRect(data.offsets.popper);
+
+            var shiftOffsets = {
+                y: {
+                    start:  { top: reference.top },
+                    end:    { top: reference.top + reference.height - popper.height }
+                },
+                x: {
+                    start:  { left: reference.left },
+                    end:    { left: reference.left + reference.width - popper.width }
+                }
+            };
+
+            var axis = ['bottom', 'top'].indexOf(basePlacement) !== -1 ? 'x' : 'y';
+
+            data.offsets.popper = Object.assign(popper, shiftOffsets[axis][shiftVariation]);
+        }
+
+        return data;
+    };
+
+
+    /**
+     * Modifier used to make sure the popper does not overflows from it's boundaries
+     * @method
+     * @memberof Popper.modifiers
+     * @argument {Object} data - The data object generated by `update` method
+     * @returns {Object} The data object, properly modified
+     */
+    Popper.prototype.modifiers.preventOverflow = function(data) {
+        var order = this._options.preventOverflowOrder;
+        var popper = getPopperClientRect(data.offsets.popper);
+
+        var check = {
+            left: function() {
+                var left = popper.left;
+                if (popper.left < data.boundaries.left) {
+                    left = Math.max(popper.left, data.boundaries.left);
+                }
+                return { left: left };
+            },
+            right: function() {
+                var left = popper.left;
+                if (popper.right > data.boundaries.right) {
+                    left = Math.min(popper.left, data.boundaries.right - popper.width);
+                }
+                return { left: left };
+            },
+            top: function() {
+                var top = popper.top;
+                if (popper.top < data.boundaries.top) {
+                    top = Math.max(popper.top, data.boundaries.top);
+                }
+                return { top: top };
+            },
+            bottom: function() {
+                var top = popper.top;
+                if (popper.bottom > data.boundaries.bottom) {
+                    top = Math.min(popper.top, data.boundaries.bottom - popper.height);
+                }
+                return { top: top };
+            }
+        };
+
+        order.forEach(function(direction) {
+            data.offsets.popper = Object.assign(popper, check[direction]());
+        });
+
+        return data;
+    };
+
+    /**
+     * Modifier used to make sure the popper is always near its reference
+     * @method
+     * @memberof Popper.modifiers
+     * @argument {Object} data - The data object generated by _update method
+     * @returns {Object} The data object, properly modified
+     */
+    Popper.prototype.modifiers.keepTogether = function(data) {
+        var popper  = getPopperClientRect(data.offsets.popper);
+        var reference = data.offsets.reference;
+        var f = Math.floor;
+
+        if (popper.right < f(reference.left)) {
+            data.offsets.popper.left = f(reference.left) - popper.width;
+        }
+        if (popper.left > f(reference.right)) {
+            data.offsets.popper.left = f(reference.right);
+        }
+        if (popper.bottom < f(reference.top)) {
+            data.offsets.popper.top = f(reference.top) - popper.height;
+        }
+        if (popper.top > f(reference.bottom)) {
+            data.offsets.popper.top = f(reference.bottom);
+        }
+
+        return data;
+    };
+
+    /**
+     * Modifier used to flip the placement of the popper when the latter is starting overlapping its reference element.
+     * Requires the `preventOverflow` modifier before it in order to work.
+     * **NOTE:** This modifier will run all its previous modifiers everytime it tries to flip the popper!
+     * @method
+     * @memberof Popper.modifiers
+     * @argument {Object} data - The data object generated by _update method
+     * @returns {Object} The data object, properly modified
+     */
+    Popper.prototype.modifiers.flip = function(data) {
+        // check if preventOverflow is in the list of modifiers before the flip modifier.
+        // otherwise flip would not work as expected.
+        if (!this.isModifierRequired(this.modifiers.flip, this.modifiers.preventOverflow)) {
+            console.warn('WARNING: preventOverflow modifier is required by flip modifier in order to work, be sure to include it before flip!');
+            return data;
+        }
+
+        if (data.flipped && data.placement === data._originalPlacement) {
+            // seems like flip is trying to loop, probably there's not enough space on any of the flippable sides
+            return data;
+        }
+
+        var placement = data.placement.split('-')[0];
+        var placementOpposite = getOppositePlacement(placement);
+        var variation = data.placement.split('-')[1] || '';
+
+        var flipOrder = [];
+        if(this._options.flipBehavior === 'flip') {
+            flipOrder = [
+                placement,
+                placementOpposite
+            ];
+        } else {
+            flipOrder = this._options.flipBehavior;
+        }
+
+        flipOrder.forEach(function(step, index) {
+            if (placement !== step || flipOrder.length === index + 1) {
+                return;
+            }
+
+            placement = data.placement.split('-')[0];
+            placementOpposite = getOppositePlacement(placement);
+
+            var popperOffsets = getPopperClientRect(data.offsets.popper);
+
+            // this boolean is used to distinguish right and bottom from top and left
+            // they need different computations to get flipped
+            var a = ['right', 'bottom'].indexOf(placement) !== -1;
+
+            // using Math.floor because the reference offsets may contain decimals we are not going to consider here
+            if (
+                a && Math.floor(data.offsets.reference[placement]) > Math.floor(popperOffsets[placementOpposite]) ||
+                !a && Math.floor(data.offsets.reference[placement]) < Math.floor(popperOffsets[placementOpposite])
+            ) {
+                // we'll use this boolean to detect any flip loop
+                data.flipped = true;
+                data.placement = flipOrder[index + 1];
+                if (variation) {
+                    data.placement += '-' + variation;
+                }
+                data.offsets.popper = this._getOffsets(this._popper, this._reference, data.placement).popper;
+
+                data = this.runModifiers(data, this._options.modifiers, this._flip);
+            }
+        }.bind(this));
+        return data;
+    };
+
+    /**
+     * Modifier used to add an offset to the popper, useful if you more granularity positioning your popper.
+     * The offsets will shift the popper on the side of its reference element.
+     * @method
+     * @memberof Popper.modifiers
+     * @argument {Object} data - The data object generated by _update method
+     * @returns {Object} The data object, properly modified
+     */
+    Popper.prototype.modifiers.offset = function(data) {
+        var offset = this._options.offset;
+        var popper  = data.offsets.popper;
+
+        if (data.placement.indexOf('left') !== -1) {
+            popper.top -= offset;
+        }
+        else if (data.placement.indexOf('right') !== -1) {
+            popper.top += offset;
+        }
+        else if (data.placement.indexOf('top') !== -1) {
+            popper.left -= offset;
+        }
+        else if (data.placement.indexOf('bottom') !== -1) {
+            popper.left += offset;
+        }
+        return data;
+    };
+
+    /**
+     * Modifier used to move the arrows on the edge of the popper to make sure them are always between the popper and the reference element
+     * It will use the CSS outer size of the arrow element to know how many pixels of conjuction are needed
+     * @method
+     * @memberof Popper.modifiers
+     * @argument {Object} data - The data object generated by _update method
+     * @returns {Object} The data object, properly modified
+     */
+    Popper.prototype.modifiers.arrow = function(data) {
+        var arrow  = this._options.arrowElement;
+
+        // if the arrowElement is a string, suppose it's a CSS selector
+        if (typeof arrow === 'string') {
+            arrow = this._popper.querySelector(arrow);
+        }
+
+        // if arrow element is not found, don't run the modifier
+        if (!arrow) {
+            return data;
+        }
+
+        // the arrow element must be child of its popper
+        if (!this._popper.contains(arrow)) {
+            console.warn('WARNING: `arrowElement` must be child of its popper element!');
+            return data;
+        }
+
+        // arrow depends on keepTogether in order to work
+        if (!this.isModifierRequired(this.modifiers.arrow, this.modifiers.keepTogether)) {
+            console.warn('WARNING: keepTogether modifier is required by arrow modifier in order to work, be sure to include it before arrow!');
+            return data;
+        }
+
+        var arrowStyle  = {};
+        var placement   = data.placement.split('-')[0];
+        var popper      = getPopperClientRect(data.offsets.popper);
+        var reference   = data.offsets.reference;
+        var isVertical  = ['left', 'right'].indexOf(placement) !== -1;
+
+        var len         = isVertical ? 'height' : 'width';
+        var side        = isVertical ? 'top' : 'left';
+        var altSide     = isVertical ? 'left' : 'top';
+        var opSide      = isVertical ? 'bottom' : 'right';
+        var arrowSize   = getOuterSizes(arrow)[len];
+
+        //
+        // extends keepTogether behavior making sure the popper and its reference have enough pixels in conjuction
+        //
+
+        // top/left side
+        if (reference[opSide] - arrowSize < popper[side]) {
+            data.offsets.popper[side] -= popper[side] - (reference[opSide] - arrowSize);
+        }
+        // bottom/right side
+        if (reference[side] + arrowSize > popper[opSide]) {
+            data.offsets.popper[side] += (reference[side] + arrowSize) - popper[opSide];
+        }
+
+        // compute center of the popper
+        var center = reference[side] + (reference[len] / 2) - (arrowSize / 2);
+
+        // Compute the sideValue using the updated popper offsets
+        var sideValue = center - getPopperClientRect(data.offsets.popper)[side];
+
+        // prevent arrow from being placed not contiguously to its popper
+        sideValue = Math.max(Math.min(popper[len] - arrowSize, sideValue), 0);
+        arrowStyle[side] = sideValue;
+        arrowStyle[altSide] = ''; // make sure to remove any old style from the arrow
+
+        data.offsets.arrow = arrowStyle;
+        data.arrowElement = arrow;
+
+        return data;
+    };
+
+
+    //
+    // Helpers
+    //
+
+    /**
+     * Get the outer sizes of the given element (offset size + margins)
+     * @function
+     * @ignore
+     * @argument {Element} element
+     * @returns {Object} object containing width and height properties
+     */
+    function getOuterSizes(element) {
+        // NOTE: 1 DOM access here
+        var _display = element.style.display, _visibility = element.style.visibility;
+        element.style.display = 'block'; element.style.visibility = 'hidden';
+        var calcWidthToForceRepaint = element.offsetWidth;
+
+        // original method
+        var styles = root.getComputedStyle(element);
+        var x = parseFloat(styles.marginTop) + parseFloat(styles.marginBottom);
+        var y = parseFloat(styles.marginLeft) + parseFloat(styles.marginRight);
+        var result = { width: element.offsetWidth + y, height: element.offsetHeight + x };
+
+        // reset element styles
+        element.style.display = _display; element.style.visibility = _visibility;
+        return result;
+    }
+
+    /**
+     * Get the opposite placement of the given one/
+     * @function
+     * @ignore
+     * @argument {String} placement
+     * @returns {String} flipped placement
+     */
+    function getOppositePlacement(placement) {
+        var hash = {left: 'right', right: 'left', bottom: 'top', top: 'bottom' };
+        return placement.replace(/left|right|bottom|top/g, function(matched){
+            return hash[matched];
+        });
+    }
+
+    /**
+     * Given the popper offsets, generate an output similar to getBoundingClientRect
+     * @function
+     * @ignore
+     * @argument {Object} popperOffsets
+     * @returns {Object} ClientRect like output
+     */
+    function getPopperClientRect(popperOffsets) {
+        var offsets = Object.assign({}, popperOffsets);
+        offsets.right = offsets.left + offsets.width;
+        offsets.bottom = offsets.top + offsets.height;
+        return offsets;
+    }
+
+    /**
+     * Given an array and the key to find, returns its index
+     * @function
+     * @ignore
+     * @argument {Array} arr
+     * @argument keyToFind
+     * @returns index or null
+     */
+    function getArrayKeyIndex(arr, keyToFind) {
+        var i = 0, key;
+        for (key in arr) {
+            if (arr[key] === keyToFind) {
+                return i;
+            }
+            i++;
+        }
+        return null;
+    }
+
+    /**
+     * Get CSS computed property of the given element
+     * @function
+     * @ignore
+     * @argument {Eement} element
+     * @argument {String} property
+     */
+    function getStyleComputedProperty(element, property) {
+        // NOTE: 1 DOM access here
+        var css = root.getComputedStyle(element, null);
+        return css[property];
+    }
+
+    /**
+     * Returns the offset parent of the given element
+     * @function
+     * @ignore
+     * @argument {Element} element
+     * @returns {Element} offset parent
+     */
+    function getOffsetParent(element) {
+        // NOTE: 1 DOM access here
+        var offsetParent = element.offsetParent;
+        return offsetParent === root.document.body || !offsetParent ? root.document.documentElement : offsetParent;
+    }
+
+    /**
+     * Returns the scrolling parent of the given element
+     * @function
+     * @ignore
+     * @argument {Element} element
+     * @returns {Element} offset parent
+     */
+    function getScrollParent(element) {
+        if (element === root.document) {
+            // Firefox puts the scrollTOp value on `documentElement` instead of `body`, we then check which of them is
+            // greater than 0 and return the proper element
+            if (root.document.body.scrollTop) {
+                return root.document.body;
+            } else {
+                return root.document.documentElement;
+            }
+        }
+
+        // Firefox want us to check `-x` and `-y` variations as well
+        if (
+            ['scroll', 'auto'].indexOf(getStyleComputedProperty(element, 'overflow')) !== -1 ||
+            ['scroll', 'auto'].indexOf(getStyleComputedProperty(element, 'overflow-x')) !== -1 ||
+            ['scroll', 'auto'].indexOf(getStyleComputedProperty(element, 'overflow-y')) !== -1
+        ) {
+            // If the detected scrollParent is body, we perform an additional check on its parentNode
+            // in this way we'll get body if the browser is Chrome-ish, or documentElement otherwise
+            // fixes issue #65
+            return element === root.document.body ? getScrollParent(element.parentNode) : element;
+        }
+        return element.parentNode ? getScrollParent(element.parentNode) : element;
+    }
+
+    /**
+     * Check if the given element is fixed or is inside a fixed parent
+     * @function
+     * @ignore
+     * @argument {Element} element
+     * @argument {Element} customContainer
+     * @returns {Boolean} answer to "isFixed?"
+     */
+    function isFixed(element) {
+        if (element === root.document.body || element.nodeName === 'HTML') {
+            return false;
+        }
+        if (getStyleComputedProperty(element, 'position') === 'fixed') {
+            return true;
+        }
+        return element.parentNode ? isFixed(element.parentNode) : element;
+    }
+
+    /**
+     * Check if the given element has transforms applied to itself or a parent
+     * @param  {Element} element
+     * @return {Boolean} answer to "isTransformed?"
+     */
+    function isTransformed(element) {
+      if (element === root.document.body) {
+          return false;
+      }
+      if (getStyleComputedProperty(element, 'transform') !== 'none') {
+          return true;
+      }
+      return element.parentNode ? isTransformed(element.parentNode) : element;
+    }
+
+    /**
+     * Set the style to the given popper
+     * @function
+     * @ignore
+     * @argument {Element} element - Element to apply the style to
+     * @argument {Object} styles - Object with a list of properties and values which will be applied to the element
+     */
+    function setStyle(element, styles) {
+        function is_numeric(n) {
+            return (n !== '' && !isNaN(parseFloat(n)) && isFinite(n));
+        }
+        Object.keys(styles).forEach(function(prop) {
+            var unit = '';
+            // add unit if the value is numeric and is one of the following
+            if (['width', 'height', 'top', 'right', 'bottom', 'left'].indexOf(prop) !== -1 && is_numeric(styles[prop])) {
+                unit = 'px';
+            }
+            element.style[prop] = styles[prop] + unit;
+        });
+    }
+
+    /**
+     * Check if the given variable is a function
+     * @function
+     * @ignore
+     * @argument {Element} element - Element to check
+     * @returns {Boolean} answer to: is a function?
+     */
+    function isFunction(functionToCheck) {
+        var getType = {};
+        return functionToCheck && getType.toString.call(functionToCheck) === '[object Function]';
+    }
+
+    /**
+     * Get the position of the given element, relative to its offset parent
+     * @function
+     * @ignore
+     * @param {Element} element
+     * @return {Object} position - Coordinates of the element and its `scrollTop`
+     */
+    function getOffsetRect(element) {
+        var elementRect = {
+            width: element.offsetWidth,
+            height: element.offsetHeight,
+            left: element.offsetLeft,
+            top: element.offsetTop
+        };
+
+        elementRect.right = elementRect.left + elementRect.width;
+        elementRect.bottom = elementRect.top + elementRect.height;
+
+        // position
+        return elementRect;
+    }
+
+    /**
+     * Get bounding client rect of given element
+     * @function
+     * @ignore
+     * @param {HTMLElement} element
+     * @return {Object} client rect
+     */
+    function getBoundingClientRect(element) {
+        var rect = element.getBoundingClientRect();
+        return {
+            left: rect.left,
+            top: rect.top,
+            right: rect.right,
+            bottom: rect.bottom,
+            width: rect.right - rect.left,
+            height: rect.bottom - rect.top
+        };
+    }
+
+    /**
+     * Given an element and one of its parents, return the offset
+     * @function
+     * @ignore
+     * @param {HTMLElement} element
+     * @param {HTMLElement} parent
+     * @return {Object} rect
+     */
+    function getOffsetRectRelativeToCustomParent(element, parent, fixed, transformed) {
+        var elementRect = getBoundingClientRect(element);
+        var parentRect = getBoundingClientRect(parent);
+
+        if (fixed && !transformed) {
+            var scrollParent = getScrollParent(parent);
+            parentRect.top += scrollParent.scrollTop;
+            parentRect.bottom += scrollParent.scrollTop;
+            parentRect.left += scrollParent.scrollLeft;
+            parentRect.right += scrollParent.scrollLeft;
+        }
+
+        var rect = {
+            top: elementRect.top - parentRect.top ,
+            left: elementRect.left - parentRect.left ,
+            bottom: (elementRect.top - parentRect.top) + elementRect.height,
+            right: (elementRect.left - parentRect.left) + elementRect.width,
+            width: elementRect.width,
+            height: elementRect.height
+        };
+        return rect;
+    }
+
+    /**
+     * Get the prefixed supported property name
+     * @function
+     * @ignore
+     * @argument {String} property (camelCase)
+     * @returns {String} prefixed property (camelCase)
+     */
+    function getSupportedPropertyName(property) {
+        var prefixes = ['', 'ms', 'webkit', 'moz', 'o'];
+
+        for (var i = 0; i < prefixes.length; i++) {
+            var toCheck = prefixes[i] ? prefixes[i] + property.charAt(0).toUpperCase() + property.slice(1) : property;
+            if (typeof root.document.body.style[toCheck] !== 'undefined') {
+                return toCheck;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * The Object.assign() method is used to copy the values of all enumerable own properties from one or more source
+     * objects to a target object. It will return the target object.
+     * This polyfill doesn't support symbol properties, since ES5 doesn't have symbols anyway
+     * Source: https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Object/assign
+     * @function
+     * @ignore
+     */
+    if (!Object.assign) {
+        Object.defineProperty(Object, 'assign', {
+            enumerable: false,
+            configurable: true,
+            writable: true,
+            value: function(target) {
+                if (target === undefined || target === null) {
+                    throw new TypeError('Cannot convert first argument to object');
+                }
+
+                var to = Object(target);
+                for (var i = 1; i < arguments.length; i++) {
+                    var nextSource = arguments[i];
+                    if (nextSource === undefined || nextSource === null) {
+                        continue;
+                    }
+                    nextSource = Object(nextSource);
+
+                    var keysArray = Object.keys(nextSource);
+                    for (var nextIndex = 0, len = keysArray.length; nextIndex < len; nextIndex++) {
+                        var nextKey = keysArray[nextIndex];
+                        var desc = Object.getOwnPropertyDescriptor(nextSource, nextKey);
+                        if (desc !== undefined && desc.enumerable) {
+                            to[nextKey] = nextSource[nextKey];
+                        }
+                    }
+                }
+                return to;
+            }
+        });
+    }
+
+    if (!root.requestAnimationFrame) {
+        var lastTime = 0;
+        var vendors = ['ms', 'moz', 'webkit', 'o'];
+        for(var x = 0; x < vendors.length && !root.requestAnimationFrame; ++x) {
+            root.requestAnimationFrame = root[vendors[x]+'RequestAnimationFrame'];
+            root.cancelAnimationFrame = root[vendors[x]+'CancelAnimationFrame'] || root[vendors[x]+'CancelRequestAnimationFrame'];
+        }
+
+        if (!root.requestAnimationFrame) {
+            root.requestAnimationFrame = function(callback, element) {
+                var currTime = new Date().getTime();
+                var timeToCall = Math.max(0, 16 - (currTime - lastTime));
+                var id = root.setTimeout(function() { callback(currTime + timeToCall); },
+                                           timeToCall);
+                lastTime = currTime + timeToCall;
+                return id;
+            };
+        }
+
+        if (!root.cancelAnimationFrame) {
+            root.cancelAnimationFrame = function(id) {
+                clearTimeout(id);
+            };
+        }
+    }
+
+    return Popper;
+}));
diff --git a/admin/tool/usertours/amd/src/tour.js b/admin/tool/usertours/amd/src/tour.js
new file mode 100644 (file)
index 0000000..46a0e19
--- /dev/null
@@ -0,0 +1,1381 @@
+// jshint ignore: start
+(function (root, factory) {
+  if (typeof define === 'function' && define.amd) {
+    // AMD. Register as an anonymous module unless amdModuleId is set
+    define(["jquery","./popper"], function (a0,b1) {
+      return (root['Tour'] = factory(a0,b1));
+    });
+  } else if (typeof exports === 'object') {
+    // Node. Does not work with strict CommonJS, but
+    // only CommonJS-like environments that support module.exports,
+    // like Node.
+    module.exports = factory(require("jquery"),require("popper.js"));
+  } else {
+    root['Tour'] = factory($,Popper);
+  }
+}(this, function ($, Popper) {
+
+"use strict";
+
+/**
+ * A Tour.
+ *
+ * @class   Tour
+ * @param   {object}    config  The configuration object.
+ */
+
+var _typeof = typeof Symbol === "function" && typeof Symbol.iterator === "symbol" ? function (obj) { return typeof obj; } : function (obj) { return obj && typeof Symbol === "function" && obj.constructor === Symbol ? "symbol" : typeof obj; };
+
+function Tour(config) {
+    this.init(config);
+}
+
+/**
+ * The name of the tour.
+ *
+ * @property    {String}    tourName
+ */
+Tour.prototype.tourName;
+
+/**
+ * The original configuration as passed into the constructor.
+ *
+ * @property    {Object}    originalConfiguration
+ */
+Tour.prototype.originalConfiguration;
+
+/**
+ * The list of step listeners.
+ *
+ * @property    {Array}     listeners
+ */
+Tour.prototype.listeners;
+
+/**
+ * The list of event handlers.
+ *
+ * @property    {Object}    eventHandlers
+ */
+Tour.prototype.eventHandlers;
+
+/**
+ * The list of steps.
+ *
+ * @property    {Object[]}      steps
+ */
+Tour.prototype.steps;
+
+/**
+ * The current step node.
+ *
+ * @property    {jQuery}        currentStepNode
+ */
+Tour.prototype.currentStepNode;
+
+/**
+ * The current step number.
+ *
+ * @property    {Number}        currentStepNumber
+ */
+Tour.prototype.currentStepNumber;
+
+/**
+ * The popper for the current step.
+ *
+ * @property    {Popper}        currentStepPopper
+ */
+Tour.prototype.currentStepPopper;
+
+/**
+ * The config for the current step.
+ *
+ * @property    {Object}        currentStepConfig
+ */
+Tour.prototype.currentStepConfig;
+
+/**
+ * The template content.
+ *
+ * @property    {String}        templateContent
+ */
+Tour.prototype.templateContent;
+
+/**
+ * Initialise the tour.
+ *
+ * @method  init
+ * @param   {Object}    config  The configuration object.
+ * @chainable
+ */
+Tour.prototype.init = function (config) {
+    // Unset all handlers.
+    this.eventHandlers = {};
+
+    // Reset the current tour states.
+    this.reset();
+
+    // Store the initial configuration.
+    this.originalConfiguration = config || {};
+
+    // Apply configuration.
+    this.configure.apply(this, arguments);
+
+    return this;
+};
+
+/**
+ * Reset the current tour state.
+ *
+ * @method  reset
+ * @chainable
+ */
+Tour.prototype.reset = function () {
+    // Hide the current step.
+    this.hide();
+
+    // Unset all handlers.
+    this.eventHandlers = [];
+
+    // Unset all listeners.
+    this.resetStepListeners();
+
+    // Unset the original configuration.
+    this.originalConfiguration = {};
+
+    // Reset the current step number and list of steps.
+    this.steps = [];
+
+    // Reset the current step number.
+    this.currentStepNumber = 0;
+
+    return this;
+};
+
+/**
+ * Prepare tour configuration.
+ *
+ * @method  configure
+ * @chainable
+ */
+Tour.prototype.configure = function (config) {
+    var _this = this;
+
+    if ((typeof config === 'undefined' ? 'undefined' : _typeof(config)) === 'object') {
+        // Tour name.
+        if (typeof config.tourName !== 'undefined') {
+            this.tourName = config.tourName;
+        }
+
+        // Set up eventHandlers.
+        if (config.eventHandlers) {
+            (function () {
+                var eventName = void 0;
+                for (eventName in config.eventHandlers) {
+                    config.eventHandlers[eventName].forEach(function (handler) {
+                        this.addEventHandler(eventName, handler);
+                    }, _this);
+                }
+            })();
+        }
+
+        // Reset the step configuration.
+        this.resetStepDefaults(true);
+
+        // Configure the steps.
+        if (_typeof(config.steps) === 'object') {
+            this.steps = config.steps;
+        }
+
+        if (typeof config.template !== 'undefined') {
+            this.templateContent = config.template;
+        }
+    }
+
+    // Check that we have enough to start the tour.
+    this.checkMinimumRequirements();
+
+    return this;
+};
+
+/**
+ * Check that the configuration meets the minimum requirements.
+ *
+ * @method  checkMinimumRequirements
+ * @chainable
+ */
+Tour.prototype.checkMinimumRequirements = function () {
+    // Need a tourName.
+    if (!this.tourName) {
+        throw new Error("Tour Name required");
+    }
+
+    // Need a minimum of one step.
+    if (!this.steps || !this.steps.length) {
+        throw new Error("Steps must be specified");
+    }
+};
+
+/**
+ * Reset step default configuration.
+ *
+ * @method  resetStepDefaults
+ * @param   {Boolean}   loadOriginalConfiguration   Whether to load the original configuration supplied with the Tour.
+ * @chainable
+ */
+Tour.prototype.resetStepDefaults = function (loadOriginalConfiguration) {
+    if (typeof loadOriginalConfiguration === 'undefined') {
+        loadOriginalConfiguration = true;
+    }
+
+    this.stepDefaults = {};
+    if (!loadOriginalConfiguration || typeof this.originalConfiguration.stepDefaults === 'undefined') {
+        this.setStepDefaults({});
+    } else {
+        this.setStepDefaults(this.originalConfiguration.stepDefaults);
+    }
+
+    return this;
+};
+
+/**
+ * Set the step defaults.
+ *
+ * @method  setStepDefaults
+ * @param   {Object}    stepDefaults                The step defaults to apply to all steps
+ * @chainable
+ */
+Tour.prototype.setStepDefaults = function (stepDefaults) {
+    if (!this.stepDefaults) {
+        this.stepDefaults = {};
+    }
+    $.extend(this.stepDefaults, {
+        element: '',
+        placement: 'top',
+        delay: 0,
+        moveOnClick: false,
+        moveAfterTime: 0,
+        orphan: false,
+        direction: 1
+    }, stepDefaults);
+
+    return this;
+};
+
+/**
+ * Retrieve the current step number.
+ *
+ * @method  getCurrentStepNumber
+ * @return  {Integer}                   The current step number
+ */
+Tour.prototype.getCurrentStepNumber = function () {
+    return parseInt(this.currentStepNumber, 10);
+};
+
+/**
+ * Store the current step number.
+ *
+ * @method  setCurrentStepNumber
+ * @param   {Integer}   stepNumber      The current step number
+ * @chainable
+ */
+Tour.prototype.setCurrentStepNumber = function (stepNumber) {
+    this.currentStepNumber = stepNumber;
+};
+
+/**
+ * Get the next step number after the currently displayed step.
+ *
+ * @method  getNextStepNumber
+ * @return  {Integer}    The next step number to display
+ */
+Tour.prototype.getNextStepNumber = function (stepNumber) {
+    if (typeof stepNumber === 'undefined') {
+        stepNumber = this.getCurrentStepNumber();
+    }
+    var nextStepNumber = stepNumber + 1;
+
+    // Keep checking the remaining steps.
+    while (nextStepNumber <= this.steps.length) {
+        if (this.isStepPotentiallyVisible(this.getStepConfig(nextStepNumber))) {
+            return nextStepNumber;
+        }
+        nextStepNumber++;
+    }
+
+    return null;
+};
+
+/**
+ * Get the previous step number before the currently displayed step.
+ *
+ * @method  getPreviousStepNumber
+ * @return  {Integer}    The previous step number to display
+ */
+Tour.prototype.getPreviousStepNumber = function (stepNumber) {
+    if (typeof stepNumber === 'undefined') {
+        stepNumber = this.getCurrentStepNumber();
+    }
+    var previousStepNumber = stepNumber - 1;
+
+    // Keep checking the remaining steps.
+    while (previousStepNumber >= 0) {
+        if (this.isStepPotentiallyVisible(this.getStepConfig(previousStepNumber))) {
+            return previousStepNumber;
+        }
+        previousStepNumber--;
+    }
+
+    return null;
+};
+
+/**
+ * Is the step the final step number?
+ *
+ * @method  isLastStep
+ * @param   {Integer}   stepNumber  Step number to test
+ * @return  {Boolean}               Whether the step is the final step
+ */
+Tour.prototype.isLastStep = function (stepNumber) {
+    var nextStepNumber = this.getNextStepNumber(stepNumber);
+
+    return nextStepNumber === null;
+};
+
+/**
+ * Is the step the first step number?
+ *
+ * @method  isFirstStep
+ * @param   {Integer}   stepNumber  Step number to test
+ * @return  {Boolean}               Whether the step is the first step
+ */
+Tour.prototype.isFirstStep = function (stepNumber) {
+    var previousStepNumber = this.getPreviousStepNumber(stepNumber);
+
+    return previousStepNumber === null;
+};
+
+/**
+ * Is this step potentially visible?
+ *
+ * @method  isStepPotentiallyVisible
+ * @param   {Integer}   stepNumber  Step number to test
+ * @return  {Boolean}               Whether the step is the potentially visible
+ */
+Tour.prototype.isStepPotentiallyVisible = function (stepConfig) {
+    if (!stepConfig) {
+        // Without step config, there can be no step.
+        return false;
+    }
+
+    if (this.isStepActuallyVisible(stepConfig)) {
+        // If it is actually visible, it is already potentially visible.
+        return true;
+    }
+
+    if (typeof stepConfig.orphan !== 'undefined' && stepConfig.orphan) {
+        // Orphan steps have no target. They are always visible.
+        return true;
+    }
+
+    if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay) {
+        // Only return true if the activated has not been used yet.
+        return true;
+    }
+
+    // Not theoretically, or actually visible.
+    return false;
+};
+
+/**
+ * Is this step actually visible?
+ *
+ * @method  isStepActuallyVisible
+ * @param   {Integer}   stepNumber  Step number to test
+ * @return  {Boolean}               Whether the step is actually visible
+ */
+Tour.prototype.isStepActuallyVisible = function (stepConfig) {
+    if (!stepConfig) {
+        // Without step config, there can be no step.
+        return false;
+    }
+
+    var target = this.getStepTarget(stepConfig);
+    if (target && target.length && target.is(':visible')) {
+        // Without a target, there can be no step.
+        return !!target.length;
+    }
+
+    return false;
+};
+
+/**
+ * Go to the next step in the tour.
+ *
+ * @method  next
+ * @chainable
+ */
+Tour.prototype.next = function () {
+    return this.gotoStep(this.getNextStepNumber());
+};
+
+/**
+ * Go to the previous step in the tour.
+ *
+ * @method  previous
+ * @chainable
+ */
+Tour.prototype.previous = function () {
+    return this.gotoStep(this.getPreviousStepNumber(), -1);
+};
+
+/**
+ * Go to the specified step in the tour.
+ *
+ * @method  gotoStep
+ * @param   {Integer}   stepNumber      The step number to display
+ * @chainable
+ */
+Tour.prototype.gotoStep = function (stepNumber, direction) {
+    if (stepNumber < 0) {
+        return this.endTour();
+    }
+
+    var stepConfig = this.getStepConfig(stepNumber);
+    if (stepConfig === null) {
+        return this.endTour();
+    }
+
+    return this._gotoStep(stepConfig, direction);
+};
+
+Tour.prototype._gotoStep = function (stepConfig, direction) {
+    if (!stepConfig) {
+        return this.endTour();
+    }
+
+    if (typeof stepConfig.delay !== 'undefined' && stepConfig.delay && !stepConfig.delayed) {
+        stepConfig.delayed = true;
+        window.setTimeout(this._gotoStep.bind(this), stepConfig.delay, stepConfig, direction);
+
+        return this;
+    } else if (!stepConfig.orphan && !this.isStepActuallyVisible(stepConfig)) {
+        var fn = direction == -1 ? 'getPreviousStepNumber' : 'getNextStepNumber';
+        return this.gotoStep(this[fn](stepConfig.stepNumber), direction);
+    }
+
+    this.hide();
+
+    this.fireEventHandlers('beforeRender', stepConfig);
+    this.renderStep(stepConfig);
+    this.fireEventHandlers('afterRender', stepConfig);
+
+    return this;
+};
+
+/**
+ * Fetch the normalised step configuration for the specified step number.
+ *
+ * @method  getStepConfig
+ * @param   {Integer}   stepNumber      The step number to fetch configuration for
+ * @return  {Object}                    The step configuration
+ */
+Tour.prototype.getStepConfig = function (stepNumber) {
+    if (stepNumber === null || stepNumber < 0 || stepNumber >= this.steps.length) {
+        return null;
+    }
+
+    // Normalise the step configuration.
+    var stepConfig = this.normalizeStepConfig(this.steps[stepNumber]);
+
+    // Add the stepNumber to the stepConfig.
+    stepConfig = $.extend(stepConfig, { stepNumber: stepNumber });
+
+    return stepConfig;
+};
+
+/**
+ * Normalise the supplied step configuration.
+ *
+ * @method  normalizeStepConfig
+ * @param   {Object}    stepConfig      The step configuration to normalise
+ * @return  {Object}                    The normalised step configuration
+ */
+Tour.prototype.normalizeStepConfig = function (stepConfig) {
+
+    if (typeof stepConfig.reflex !== 'undefined' && typeof stepConfig.moveAfterClick === 'undefined') {
+        stepConfig.moveAfterClick = stepConfig.reflex;
+    }
+
+    if (typeof stepConfig.element !== 'undefined' && typeof stepConfig.target === 'undefined') {
+        stepConfig.target = stepConfig.element;
+    }
+
+    if (typeof stepConfig.content !== 'undefined' && typeof stepConfig.body === 'undefined') {
+        stepConfig.body = stepConfig.content;
+    }
+
+    stepConfig = $.extend({}, this.stepDefaults, stepConfig);
+
+    stepConfig = $.extend({}, {
+        attachTo: stepConfig.target,
+        attachPoint: 'after'
+    }, stepConfig);
+
+    return stepConfig;
+};
+
+/**
+ * Fetch the actual step target from the selector.
+ *
+ * This should not be called until after any delay has completed.
+ *
+ * @method  getStepTarget
+ * @param   {Object}    stepConfig      The step configuration
+ * @return  {$}
+ */
+Tour.prototype.getStepTarget = function (stepConfig) {
+    if (stepConfig.target) {
+        return $(stepConfig.target);
+    }
+
+    return null;
+};
+
+/**
+ * Fire any event handlers for the specified event.
+ *
+ * @param   {String}    eventName       The name of the event to handle
+ * @param   {Object}    data            Any data to pass to the event
+ * @chainable
+ */
+Tour.prototype.fireEventHandlers = function (eventName, data) {
+    if (typeof this.eventHandlers[eventName] === 'undefined') {
+        return this;
+    }
+
+    this.eventHandlers[eventName].forEach(function (thisEvent) {
+        thisEvent.call(this, data);
+    }, this);
+
+    return this;
+};
+
+/**
+ * @method  addEventHandler
+ * @param   string      eventName       The name of the event to listen for
+ * @param   function    handler         The event handler to call
+ */
+Tour.prototype.addEventHandler = function (eventName, handler) {
+    if (typeof this.eventHandlers[eventName] === 'undefined') {
+        this.eventHandlers[eventName] = [];
+    }
+
+    this.eventHandlers[eventName].push(handler);
+
+    return this;
+};
+
+/**
+ * Process listeners for the step being shown.
+ *
+ * @method  processStepListeners
+ * @param   {object}    stepConfig      The configuration for the step
+ * @chainable
+ */
+Tour.prototype.processStepListeners = function (stepConfig) {
+    this.listeners.push(
+    // Next/Previous buttons.
+    {
+        node: this.currentStepNode,
+        args: ['click', '[data-role="next"]', $.proxy(this.next, this)]
+    }, {
+        node: this.currentStepNode,
+        args: ['click', '[data-role="previous"]', $.proxy(this.previous, this)]
+    },
+
+    // Close and end tour buttons.
+    {
+        node: this.currentStepNode,
+        args: ['click', '[data-role="end"]', $.proxy(this.endTour, this)]
+    },
+
+    // Keypresses.
+    {
+        node: $('body'),
+        args: ['keydown', $.proxy(this.handleKeyDown, this)]
+    });
+
+    if (stepConfig.moveOnClick) {
+        var targetNode = this.getStepTarget(stepConfig);
+        this.listeners.push({
+            node: targetNode,
+            args: ['click', $.proxy(function (e) {
+                if ($(e.target).parents('[data-flexitour="container"]').length === 0) {
+                    // Ignore clicks when they are in the flexitour.
+                    window.setTimeout($.proxy(this.next, this), 100);
+                }
+            }, this)]
+        });
+    }
+
+    this.listeners.forEach(function (listener) {
+        listener.node.on.apply(listener.node, listener.args);
+    });
+
+    return this;
+};
+
+/**
+ * Reset step listeners.
+ *
+ * @method  resetStepListeners
+ * @chainable
+ */
+Tour.prototype.resetStepListeners = function () {
+    // Stop listening to all external handlers.
+    if (this.listeners) {
+        this.listeners.forEach(function (listener) {
+            listener.node.off.apply(listener.node, listener.args);
+        });
+    }
+    this.listeners = [];
+
+    return this;
+};
+
+/**
+ * The standard step renderer.
+ *
+ * @method  renderStep
+ * @param   {Object}    stepConfig      The step configuration of the step
+ * @chainable
+ */
+Tour.prototype.renderStep = function (stepConfig) {
+    // Store the current step configuration for later.
+    this.currentStepConfig = stepConfig;
+    this.setCurrentStepNumber(stepConfig.stepNumber);
+
+    // Fetch the template and convert it to a $ object.
+    var template = $(this.getTemplateContent());
+
+    // Title.
+    template.find('[data-placeholder="title"]').html(stepConfig.title);
+
+    // Body.
+    template.find('[data-placeholder="body"]').html(stepConfig.body);
+
+    // Is this the first step?
+    if (this.isFirstStep(stepConfig.stepNumber)) {
+        template.find('[data-role="previous"]').prop('disabled', true);
+    } else {
+        template.find('[data-role="previous"]').prop('disabled', false);
+    }
+
+    // Is this the final step?
+    if (this.isLastStep(stepConfig.stepNumber)) {
+        template.find('[data-role="next"]').prop('disabled', true);
+    } else {
+        template.find('[data-role="next"]').prop('disabled', false);
+    }
+
+    template.find('[data-role="previous"]').attr('role', 'button');
+    template.find('[data-role="next"]').attr('role', 'button');
+    template.find('[data-role="end"]').attr('role', 'button');
+
+    // Replace the template with the updated version.
+    stepConfig.template = template;
+
+    // Add to the page.
+    this.addStepToPage(stepConfig);
+
+    // Process step listeners after adding to the page.
+    // This uses the currentNode.
+    this.processStepListeners(stepConfig);
+
+    return this;
+};
+
+/**
+ * Getter for the template content.
+ *
+ * @method  getTemplateContent
+ * @return  {$}
+ */
+Tour.prototype.getTemplateContent = function () {
+    return $(this.templateContent).clone();
+};
+
+/**
+ * Helper to add a step to the page.
+ *
+ * @method  addStepToPage
+ * @param   {Object}    stepConfig      The step configuration of the step
+ * @chainable
+ */
+Tour.prototype.addStepToPage = function (stepConfig) {
+    var stepContent = stepConfig.template;
+
+    // Create the stepNode from the template data.
+    var currentStepNode = $('<span data-flexitour="container"></span>').html(stepConfig.template).hide();
+
+    // The scroll animation occurs on the body or html.
+    var animationTarget = $('body, html').stop(true, true);
+
+    if (this.isStepActuallyVisible(stepConfig)) {
+        var zIndex = this.calculateZIndex(this.getStepTarget(stepConfig));
+        if (zIndex) {
+            stepConfig.zIndex = zIndex + 1;
+        }
+
+        if (stepConfig.zIndex) {
+            currentStepNode.css('zIndex', stepConfig.zIndex + 1);
+        }
+
+        // Add the backdrop.
+        this.positionBackdrop(stepConfig);
+
+        if (stepConfig.attachPoint === 'append') {
+            $(stepConfig.attachTo).append(currentStepNode);
+            this.currentStepNode = currentStepNode;
+        } else {
+            this.currentStepNode = currentStepNode.insertAfter($(stepConfig.attachTo));
+        }
+
+        // Ensure that the step node is positioned.
+        // Some situations mean that the value is not properly calculated without this step.
+        this.currentStepNode.css({
+            top: 0,
+            left: 0
+        });
+
+        animationTarget.animate({
+            scrollTop: this.calculateScrollTop(stepConfig)
+        }).promise().then($.proxy(function () {
+            this.positionStep(stepConfig);
+            this.revealStep(stepConfig);
+        }, this));
+    } else if (stepConfig.orphan) {
+        stepConfig.isOrphan = true;
+
+        // This will be appended to the body instead.
+        stepConfig.attachTo = 'body';
+        stepConfig.attachPoint = 'append';
+
+        // Add the backdrop.
+        this.positionBackdrop(stepConfig);
+
+        // This is an orphaned step.
+        currentStepNode.addClass('orphan');
+
+        // It lives in the body.
+        $(stepConfig.attachTo).append(currentStepNode);
+        this.currentStepNode = currentStepNode;
+
+        this.currentStepNode.offset(this.calculateStepPositionInPage());
+
+        this.currentStepPopper = new Popper($('body'), this.currentStepNode[0], {
+            placement: stepConfig.placement + '-start',
+            arrowElement: '[data-role="arrow"]',
+            // Empty the modifiers. We've already placed the step and don't want it moved.
+            modifiers: []
+        });
+
+        this.revealStep(stepConfig);
+    }
+
+    return this;
+};
+
+Tour.prototype.revealStep = function (stepConfig) {
+    // Fade the step in.
+    this.currentStepNode.fadeIn('', $.proxy(function () {
+        // Announce via ARIA.
+        this.announceStep(stepConfig);
+
+        // Focus on the current step Node.
+        this.currentStepNode.focus();
+        window.setTimeout($.proxy(function () {
+            // After a brief delay, focus again.
+            // There seems to be an issue with Jaws where it only reads the dialogue title initially.
+            // This second focus causes it to read the full dialogue.
+            if (this.currentStepNode) {
+                this.currentStepNode.focus();
+            }
+        }, this), 100);
+    }, this));
+
+    return this;
+};
+
+/**
+ * Helper to announce the step on the page.
+ *
+ * @method  announceStep
+ * @param   {Object}    stepConfig      The step configuration of the step
+ * @chainable
+ */
+Tour.prototype.announceStep = function (stepConfig) {
+    // Setup the step Dialogue as per:
+    // * https://www.w3.org/TR/wai-aria-practices/#dialog_nonmodal
+    // * https://www.w3.org/TR/wai-aria-practices/#dialog_modal
+
+    // Generate an ID for the current step node.
+    var stepId = 'tour-step-' + this.tourName + '-' + stepConfig.stepNumber;
+    this.currentStepNode.attr('id', stepId);
+
+    var bodyRegion = this.currentStepNode.find('[data-placeholder="body"]').first();
+    bodyRegion.attr('id', stepId + '-body');
+    bodyRegion.attr('role', 'document');
+
+    var headerRegion = this.currentStepNode.find('[data-placeholder="title"]').first();
+    headerRegion.attr('id', stepId + '-title');
+    headerRegion.attr('aria-labelledby', stepId + '-body');
+
+    // Generally, a modal dialog has a role of dialog.
+    this.currentStepNode.attr('role', 'dialog');
+    this.currentStepNode.attr('tabindex', 0);
+    this.currentStepNode.attr('aria-labelledby', stepId + '-title');
+    this.currentStepNode.attr('aria-describedby', stepId + '-body');
+
+    // Configure ARIA attributes on the target.
+    var target = this.getStepTarget(stepConfig);
+    if (target) {
+        if (!target.attr('tabindex')) {
+            target.attr('tabindex', 0);
+        }
+
+        target.data('original-describedby', target.attr('aria-describedby')).attr('aria-describedby', stepId + '-body');
+    }
+
+    return this;
+};
+
+/**
+ * Handle key down events.
+ *
+ * @method  handleKeyDown
+ * @param   {EventFacade} e
+ */
+Tour.prototype.handleKeyDown = function (e) {
+    var tabbableSelector = 'a[href], link[href], [draggable=true], [contenteditable=true], :input:enabled, [tabindex], button';
+    switch (e.keyCode) {
+        case 27:
+            this.endTour();
+            break;
+
+        // 9 == Tab - trap focus for items with a backdrop.
+        case 9:
+            // Tab must be handled on key up only in this instance.
+            (function () {
+                if (!this.currentStepConfig.hasBackdrop) {
+                    // Trapping tab focus is only handled for those steps with a backdrop.
+                    return;
+                }
+
+                // Find all tabbable locations.
+                var activeElement = $(document.activeElement);
+                var stepTarget = this.getStepTarget(this.currentStepConfig);
+                var tabbableNodes = $(tabbableSelector);
+                var currentIndex = void 0;
+                tabbableNodes.filter(function (index, element) {
+                    if (activeElement.is(element)) {
+                        currentIndex = index;
+                        return false;
+                    }
+                });
+
+                var nextIndex = void 0;
+                var nextNode = void 0;
+                var focusRelevant = void 0;
+                if (currentIndex) {
+                    var direction = 1;
+                    if (e.shiftKey) {
+                        direction = -1;
+                    }
+                    nextIndex = currentIndex;
+                    do {
+                        nextIndex += direction;
+                        nextNode = $(tabbableNodes[nextIndex]);
+                    } while (nextNode.length && nextNode.is(':disabled') || nextNode.is(':hidden'));
+                    if (nextNode.length) {
+                        // A new f
+                        focusRelevant = nextNode.closest(stepTarget).length;
+                        focusRelevant = focusRelevant || nextNode.closest(this.currentStepNode).length;
+                    } else {
+                        // Unable to find the target somehow.
+                        focusRelevant = false;
+                    }
+                }
+
+                if (focusRelevant) {
+                    nextNode.focus();
+                } else {
+                    if (e.shiftKey) {
+                        // Focus on the last tabbable node in the step.
+                        this.currentStepNode.find(tabbableSelector).last().focus();
+                    } else {
+                        if (this.currentStepConfig.isOrphan) {
+                            // Focus on the step - there is no target.
+                            this.currentStepNode.focus();
+                        } else {
+                            // Focus on the step target.
+                            stepTarget.focus();
+                        }
+                    }
+                }
+                e.preventDefault();
+            }).call(this);
+            break;
+    }
+};
+
+/**
+ * Start the current tour.
+ *
+ * @method  startTour
+ * @param   {Integer}   startAt     Which step number to start at. If not specified, starts at the last point.
+ * @chainable
+ */
+Tour.prototype.startTour = function (startAt) {
+    if (typeof startAt === 'undefined') {
+        startAt = this.getCurrentStepNumber();
+    }
+
+    this.fireEventHandlers('beforeStart', startAt);
+    this.gotoStep(startAt);
+    this.fireEventHandlers('afterStart', startAt);
+
+    return this;
+};
+
+/**
+ * Restart the tour from the beginning, resetting the completionlag.
+ *
+ * @method  restartTour
+ * @chainable
+ */
+Tour.prototype.restartTour = function () {
+    return this.startTour(0);
+};
+
+/**
+ * End the current tour.
+ *
+ * @method  endTour
+ * @chainable
+ */
+Tour.prototype.endTour = function () {
+    this.fireEventHandlers('beforeEnd');
+
+    if (this.currentStepConfig) {
+        var previousTarget = this.getStepTarget(this.currentStepConfig);
+        if (previousTarget) {
+            if (!previousTarget.attr('tabindex')) {
+                previousTarget.attr('tabindex', '-1');
+            }
+            previousTarget.focus();
+        }
+    }
+
+    this.hide(true);
+
+    this.fireEventHandlers('afterEnd');
+
+    return this;
+};
+
+/**
+ * Hide any currently visible steps.
+ *
+ * @method hide
+ * @chainable
+ */
+Tour.prototype.hide = function (transition) {
+    this.fireEventHandlers('beforeHide');
+
+    if (this.currentStepNode && this.currentStepNode.length) {
+        this.currentStepNode.hide();
+        if (this.currentStepPopper) {
+            this.currentStepPopper.destroy();
+        }
+    }
+
+    // Restore original target configuration.
+    if (this.currentStepConfig) {
+        var target = this.getStepTarget(this.currentStepConfig);
+        if (target) {
+            if (target.data('original-labelledby')) {
+                target.attr('aria-labelledby', target.data('original-labelledby'));
+            }
+
+            if (target.data('original-describedby')) {
+                target.attr('aria-describedby', target.data('original-describedby'));
+            }
+
+            if (target.data('original-tabindex')) {
+                target.attr('tabindex', target.data('tabindex'));
+            }
+        }
+
+        // Clear the step configuration.
+        this.currentStepConfig = null;
+    }
+
+    var fadeTime = 0;
+    if (transition) {
+        fadeTime = 400;
+    }
+
+    // Remove the backdrop features.
+    $('[data-flexitour="step-background"]').remove();
+    $('[data-flexitour="step-backdrop"]').removeAttr('data-flexitour');
+    $('[data-flexitour="backdrop"]').fadeOut(fadeTime, function () {
+        $(this).remove();
+    });
+
+    // Reset the listeners.
+    this.resetStepListeners();
+
+    this.fireEventHandlers('afterHide');
+
+    this.currentStepNode = null;
+    this.currentStepPopper = null;
+    return this;
+};
+
+/**
+ * Show the current steps.
+ *
+ * @method show
+ * @chainable
+ */
+Tour.prototype.show = function () {
+    // Show the current step.
+    var startAt = this.getCurrentStepNumber();
+
+    return this.gotoStep(startAt);
+};
+
+/**
+ * Return the current step node.
+ *
+ * @method  getStepContainer
+ * @return  {jQuery}
+ */
+Tour.prototype.getStepContainer = function () {
+    return $(this.currentStepNode);
+};
+
+/**
+ * Calculate scrollTop.
+ *
+ * @method  calculateScrollTop
+ * @param   {Object}    stepConfig      The step configuration of the step
+ * @return  {Number}
+ */
+Tour.prototype.calculateScrollTop = function (stepConfig) {
+    var scrollTop = $(window).scrollTop();
+    var viewportHeight = $(window).height();
+    var targetNode = this.getStepTarget(stepConfig);
+
+    if (stepConfig.placement === 'top') {
+        // If the placement is top, center scroll at the top of the target.
+        scrollTop = targetNode.offset().top - viewportHeight / 2;
+    } else if (stepConfig.placement === 'bottom') {
+        // If the placement is bottom, center scroll at the bottom of the target.
+        scrollTop = targetNode.offset().top + targetNode.height() - viewportHeight / 2;
+    } else if (targetNode.height() <= viewportHeight * 0.8) {
+        // If the placement is left/right, and the target fits in the viewport, centre screen on the target
+        scrollTop = targetNode.offset().top - (viewportHeight - targetNode.height()) / 2;
+    } else {
+        // If the placement is left/right, and the target is bigger than the viewport, set scrollTop to target.top + buffer
+        // and change step attachmentTarget to top+.
+        scrollTop = targetNode.offset().top - viewportHeight * 0.2;
+    }
+
+    // Never scroll over the top.
+    scrollTop = Math.max(0, scrollTop);
+
+    // Never scroll beyond the bottom.
+    scrollTop = Math.min($(document).height() - viewportHeight, scrollTop);
+
+    return Math.ceil(scrollTop);
+};
+
+/**
+ * Calculate dialogue position for page middle.
+ *
+ * @method  calculateScrollTop
+ * @return  {Number}
+ */
+Tour.prototype.calculateStepPositionInPage = function () {
+    var viewportHeight = $(window).height();
+    var stepHeight = this.currentStepNode.height();
+    var scrollTop = $(window).scrollTop();
+
+    var viewportWidth = $(window).width();
+    var stepWidth = this.currentStepNode.width();
+    var scrollLeft = $(window).scrollLeft();
+
+    return {
+        top: Math.ceil(scrollTop + (viewportHeight - stepHeight) / 2),
+        left: Math.ceil(scrollLeft + (viewportWidth - stepWidth) / 2)
+    };
+};
+
+/**
+ * Position the step on the page.
+ *
+ * @method  positionStep
+ * @param   {Object}    stepConfig      The step configuration of the step
+ * @chainable
+ */
+Tour.prototype.positionStep = function (stepConfig) {
+    var content = this.currentStepNode;
+    if (!content || !content.length) {
+        // Unable to find the step node.
+        return this;
+    }
+
+    var flipBehavior = void 0;
+    switch (stepConfig.placement) {
+        case 'left':
+            flipBehavior = ['left', 'right', 'top', 'bottom'];
+            break;
+        case 'right':
+            flipBehavior = ['right', 'left', 'top', 'bottom'];
+            break;
+        case 'top':
+            flipBehavior = ['top', 'bottom', 'right', 'left'];
+            break;
+        case 'bottom':
+            flipBehavior = ['bottom', 'top', 'right', 'left'];
+            break;
+        default:
+            flipBehavior = 'flip';
+            break;
+    }
+
+    var target = this.getStepTarget(stepConfig);
+    var background = $('[data-flexitour="step-background"]');
+    if (background.length) {
+        target = background;
+    }
+
+    this.currentStepPopper = new Popper(target, content[0], {
+        placement: stepConfig.placement + '-start',
+        removeOnDestroy: true,
+        flipBehavior: flipBehavior,
+        arrowElement: '[data-role="arrow"]',
+        modifiers: ['shift', 'offset', 'preventOverflow', 'keepTogether', this.centerPopper, 'arrow', 'flip', 'applyStyle']
+    });
+
+    return this;
+};
+
+/**
+ * Add the backdrop.
+ *
+ * @method  positionBackdrop
+ * @param   {Object}    stepConfig      The step configuration of the step
+ * @chainable
+ */
+Tour.prototype.positionBackdrop = function (stepConfig) {
+    if (stepConfig.backdrop) {
+        this.currentStepConfig.hasBackdrop = true;
+        var backdrop = $('<div data-flexitour="backdrop"></div>');
+
+        if (stepConfig.zIndex) {
+            if (stepConfig.attachPoint === 'append') {
+                $(stepConfig.attachTo).append(backdrop);
+            } else {
+                backdrop.insertAfter($(stepConfig.attachTo));
+            }
+        } else {
+            $('body').append(backdrop);
+        }
+
+        if (this.isStepActuallyVisible(stepConfig)) {
+            // The step has a visible target.
+            // Punch a hole through the backdrop.
+            var background = $('<div data-flexitour="step-background"></div>');
+
+            var targetNode = this.getStepTarget(stepConfig);
+
+            var buffer = 10;
+
+            var colorNode = targetNode;
+            if (buffer) {
+                colorNode = $('body');
+            }
+
+            background.css({
+                width: targetNode.outerWidth() + buffer + buffer,
+                height: targetNode.outerHeight() + buffer + buffer,
+                left: targetNode.offset().left - buffer,
+                top: targetNode.offset().top - buffer,
+                backgroundColor: this.calculateInherittedBackgroundColor(colorNode)
+            });
+
+            if (targetNode.offset().left < buffer) {
+                background.css({
+                    width: targetNode.outerWidth() + targetNode.offset().left + buffer,
+                    left: targetNode.offset().left
+                });
+            }
+
+            if (targetNode.offset().top < buffer) {
+                background.css({
+                    height: targetNode.outerHeight() + targetNode.offset().top + buffer,
+                    top: targetNode.offset().top
+                });
+            }
+
+            var targetRadius = targetNode.css('borderRadius');
+            if (targetRadius && targetRadius !== $('body').css('borderRadius')) {
+                background.css('borderRadius', targetRadius);
+            }
+
+            var targetPosition = this.calculatePosition(targetNode);
+            if (targetPosition === 'fixed') {
+                background.css('top', 0);
+            }
+
+            var fader = background.clone();
+            fader.css({
+                backgroundColor: backdrop.css('backgroundColor'),
+                opacity: backdrop.css('opacity')
+            });
+            fader.attr('data-flexitour', 'step-background-fader');
+
+            if (stepConfig.zIndex) {
+                if (stepConfig.attachPoint === 'append') {
+                    $(stepConfig.attachTo).append(background);
+                } else {
+                    fader.insertAfter($(stepConfig.attachTo));
+                    background.insertAfter($(stepConfig.attachTo));
+                }
+            } else {
+                $('body').append(fader);
+                $('body').append(background);
+            }
+
+            // Add the backdrop data to the actual target.
+            // This is the part which actually does the work.
+            targetNode.attr('data-flexitour', 'step-backdrop');
+
+            if (stepConfig.zIndex) {
+                backdrop.css('zIndex', stepConfig.zIndex);
+                background.css('zIndex', stepConfig.zIndex + 1);
+                targetNode.css('zIndex', stepConfig.zIndex + 2);
+            }
+
+            fader.fadeOut('2000', function () {
+                $(this).remove();
+            });
+        }
+    }
+    return this;
+};
+
+/**
+ * Calculate the inheritted z-index.
+ *
+ * @method  calculateZIndex
+ * @param   {jQuery}    elem                        The element to calculate z-index for
+ * @return  {Number}                                Calculated z-index
+ */
+Tour.prototype.calculateZIndex = function (elem) {
+    elem = $(elem);
+    while (elem.length && elem[0] !== document) {
+        // Ignore z-index if position is set to a value where z-index is ignored by the browser
+        // This makes behavior of this function consistent across browsers
+        // WebKit always returns auto if the element is positioned.
+        var position = elem.css("position");
+        if (position === "absolute" || position === "relative" || position === "fixed") {
+            // IE returns 0 when zIndex is not specified
+            // other browsers return a string
+            // we ignore the case of nested elements with an explicit value of 0
+            // <div style="z-index: -10;"><div style="z-index: 0;"></div></div>
+            var value = parseInt(elem.css("zIndex"), 10);
+            if (!isNaN(value) && value !== 0) {
+                return value;
+            }
+        }
+        elem = elem.parent();
+    }
+
+    return 0;
+};
+
+/**
+ * Calculate the inheritted background colour.
+ *
+ * @method  calculateInherittedBackgroundColor
+ * @param   {jQuery}    elem                        The element to calculate colour for
+ * @return  {String}                                Calculated background colour
+ */
+Tour.prototype.calculateInherittedBackgroundColor = function (elem) {
+    // Use a fake node to compare each element against.
+    var fakeNode = $('<div>').hide();
+    $('body').append(fakeNode);
+    var fakeElemColor = fakeNode.css('backgroundColor');
+    fakeNode.remove();
+
+    elem = $(elem);
+    while (elem.length && elem[0] !== document) {
+        var color = elem.css('backgroundColor');
+        if (color !== fakeElemColor) {
+            return color;
+        }
+        elem = elem.parent();
+    }
+
+    return null;
+};
+
+/**
+ * Calculate the inheritted position.
+ *
+ * @method  calculatePosition
+ * @param   {jQuery}    elem                        The element to calculate position for
+ * @return  {String}                                Calculated position
+ */
+Tour.prototype.calculatePosition = function (elem) {
+    elem = $(elem);
+    while (elem.length && elem[0] !== document) {
+        var position = elem.css('position');
+        if (position !== 'static') {
+            return position;
+        }
+        elem = elem.parent();
+    }
+
+    return null;
+};
+
+Tour.prototype.centerPopper = function (data) {
+    if (!this.isModifierRequired(Tour.prototype.centerPopper, this.modifiers.keepTogether)) {
+        console.warn('WARNING: keepTogether modifier is required by centerPopper modifier in order to work, be sure to include it before arrow!');
+        return data;
+    }
+
+    var placement = data.placement.split('-')[0];
+    var reference = data.offsets.reference;
+    var isVertical = ['left', 'right'].indexOf(placement) !== -1;
+
+    var len = isVertical ? 'height' : 'width';
+    var side = isVertical ? 'top' : 'left';
+
+    data.offsets.popper[side] += Math.max(reference[len] / 2 - data.offsets.popper[len] / 2, 0);
+
+    return data;
+};
+
+if ((typeof exports === 'undefined' ? 'undefined' : _typeof(exports)) === 'object') {
+    module.exports = Tour;
+}
+
+return Tour;
+
+}));
diff --git a/admin/tool/usertours/amd/src/usertours.js b/admin/tool/usertours/amd/src/usertours.js
new file mode 100644 (file)
index 0000000..e0d877d
--- /dev/null
@@ -0,0 +1,238 @@
+/**
+ * User tour control library.
+ *
+ * @module     tool_usertours/usertours
+ * @class      usertours
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ */
+define(
+['core/ajax', 'tool_usertours/tour', 'jquery', 'core/templates', 'core/str', 'core/log', 'core/notification'],
+function(ajax, BootstrapTour, $, templates, str, log, notification) {
+    var usertours = {
+        tourId: null,
+
+        currentTour: null,
+
+        context: null,
+
+        /**
+         * Initialise the user tour for the current page.
+         *
+         * @method  init
+         * @param   {Number}    tourId      The ID of the tour to start.
+         * @param   {Bool}      startTour   Attempt to start the tour now.
+         * @param   {Number}    context     The context of the current page.
+         */
+        init: function(tourId, startTour, context) {
+            // Only one tour per page is allowed.
+            usertours.tourId = tourId;
+
+            usertours.context = context;
+
+            if (typeof startTour === 'undefined') {
+                startTour = true;
+            }
+
+            if (startTour) {
+                // Fetch the tour configuration.
+                usertours.fetchTour(tourId);
+            }
+
+            usertours.addResetLink();
+            // Watch for the reset link.
+            $('body').on('click', '[data-action="tool_usertours/resetpagetour"]', function(e) {
+                e.preventDefault();
+                usertours.resetTourState(usertours.tourId);
+            });
+        },
+
+        /**
+         * 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.
+         */
+        fetchTour: function(tourId) {
+            $.when(
+                ajax.call([
+                    {
+                        methodname: 'tool_usertours_fetch_and_start_tour',
+                        args: {
+                            tourid:     tourId,
+                            context:    usertours.context,
+                            pageurl:    window.location.href,
+                        }
+                    }
+                ])[0],
+                templates.render('tool_usertours/tourstep', {})
+            ).then(function(response, template) {
+                usertours.startBootstrapTour(tourId, template[0], response.tourconfig);
+            }).fail(notification.exception);
+        },
+
+        /**
+         * Add a reset link to the page.
+         *
+         * @method  addResetLink
+         */
+        addResetLink: function() {
+            str.get_string('resettouronpage', 'tool_usertours')
+                .done(function(s) {
+                    // Grab the last item in the page of these.
+                    $('footer, .logininfo')
+                    .last()
+                    .append(
+                        '<div class="usertour">' +
+                            '<a href="#" data-action="tool_usertours/resetpagetour">' +
+                                s +
+                            '</a>' +
+                        '</div>'
+                    );
+                });
+        },
+
+        /**
+         * 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.
+         */
+        startBootstrapTour: function(tourId, template, tourConfig) {
+            if (usertours.currentTour) {
+                // End the current tour, but disable end tour handler.
+                tourConfig.onEnd = null;
+                usertours.currentTour.endTour();
+                delete usertours.currentTour;
+            }
+
+            // Normalize for the new library.
+            tourConfig.eventHandlers = {
+                afterEnd: [usertours.markTourComplete],
+                afterRender: [usertours.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;
+            });
+
+            usertours.currentTour = new BootstrapTour(tourConfig);
+            usertours.currentTour.startTour();
+        },
+
+        /**
+         * Mark the specified step as being shownd by the user.
+         *
+         * @method  markStepShown
+         */
+        markStepShown: function() {
+            var stepConfig = this.getStepConfig(this.getCurrentStepNumber());
+            $.when(
+                ajax.call([
+                    {
+                        methodname: 'tool_usertours_step_shown',
+                        args: {
+                            tourid:     usertours.tourId,
+                            context:    usertours.context,
+                            pageurl:    window.location.href,
+                            stepid:     stepConfig.stepid,
+                            stepindex:  this.getCurrentStepNumber(),
+                        }
+                    }
+                ])[0]
+            ).fail(log.error);
+        },
+
+        /**
+         * Mark the specified tour as being completed by the user.
+         *
+         * @method  markTourComplete
+         */
+        markTourComplete: function() {
+            var stepConfig = this.getStepConfig(this.getCurrentStepNumber());
+            $.when(
+                ajax.call([
+                    {
+                        methodname: 'tool_usertours_complete_tour',
+                        args: {
+                            tourid:     usertours.tourId,
+                            context:    usertours.context,
+                            pageurl:    window.location.href,
+                            stepid:     stepConfig.stepid,
+                            stepindex:  this.getCurrentStepNumber(),
+                        }
+                    }
+                ])[0]
+            ).fail(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.
+         */
+        resetTourState: function(tourId) {
+            $.when(
+                ajax.call([
+                    {
+                        methodname: 'tool_usertours_reset_tour',
+                        args: {
+                            tourid:     tourId,
+                            context:    usertours.context,
+                            pageurl:    window.location.href,
+                        }
+                    }
+                ])[0]
+            ).then(function(response) {
+                if (response.startTour) {
+                    usertours.fetchTour(response.startTour);
+                }
+            }).fail(notification.exception);
+        }
+    };
+
+    return /** @alias module:tool_usertours/usertours */ {
+        /**
+         * Initialise the user tour for the current page.
+         *
+         * @method  init
+         * @param   {Number}    tourId      The ID of the tour to start.
+         * @param   {Bool}      startTour   Attempt to start the tour now.
+         */
+        init: usertours.init,
+
+        /**
+         * Reset the state, and restart the the tour on the current page.
+         *
+         * @method  resetTourState
+         * @param   {Number}    tourId      The ID of the tour to restart.
+         */
+        resetTourState: usertours.resetTourState
+    };
+});
diff --git a/admin/tool/usertours/classes/configuration.php b/admin/tool/usertours/classes/configuration.php
new file mode 100644 (file)
index 0000000..3caac6f
--- /dev/null
@@ -0,0 +1,118 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Step configuration detail class.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Step configuration detail class.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class configuration {
+
+    /**
+     * @var TOURDEFAULT
+     */
+    const TOURDEFAULT = 'usetourdefault';
+
+    /**
+     * Get the list of keys which can be defaulted in the tour.
+     *
+     * @return  array
+     */
+    public static function get_defaultable_keys() {
+        return [
+            'placement',
+            'orphan',
+            'backdrop',
+            'reflex',
+        ];
+    }
+
+    /**
+     * Get the default value for the specified key.
+     *
+     * @param   string          $key        The key for the specified value
+     * @return  mixed
+     */
+    public static function get_default_value($key) {
+        switch($key) {
+            case 'placement':
+                return 'bottom';
+            case 'orphan':
+            case 'backdrop':
+            case 'reflex':
+                return false;
+        }
+    }
+
+    /**
+     * Get the default value for the specified key for the step form.
+     *
+     * @param   string          $key        The key for the specified value
+     * @return  mixed
+     */
+    public static function get_step_default_value($key) {
+        switch($key) {
+            case 'placement':
+            case 'orphan':
+            case 'backdrop':
+            case 'reflex':
+                return self::TOURDEFAULT;
+        }
+    }
+
+    /**
+     * Get the list of possible placement options.
+     *
+     * @param   string          $default    The default option.
+     * @return  array
+     */
+    public static function get_placement_options($default = null) {
+        $values = [
+            'top'    => get_string('top',     'tool_usertours'),
+            'bottom' => get_string('bottom',  'tool_usertours'),
+            'left'   => get_string('left',    'tool_usertours'),
+            'right'  => get_string('right',   'tool_usertours'),
+        ];
+
+        if ($default === null) {
+            return $values;
+        }
+
+        if (!isset($values[$default])) {
+            $default = self::get_default_value('placement');
+        }
+
+        $values = array_reverse($values, true);
+        $values[self::TOURDEFAULT] = get_string('defaultvalue', 'tool_usertours', $values[$default]);
+        $values = array_reverse($values, true);
+
+        return $values;
+    }
+
+}
diff --git a/admin/tool/usertours/classes/event/step_shown.php b/admin/tool/usertours/classes/event/step_shown.php
new file mode 100644 (file)
index 0000000..3537b61
--- /dev/null
@@ -0,0 +1,146 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The tool_usertours step_shown event.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The tool_usertours step_shown event.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int       tourid:     The id of the tour
+ *      - string    pageurl:    The URL of the page viewing the tour
+ * }
+ */
+class step_shown extends \core\event\base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+        $this->data['objecttable'] = 'tool_usertours_steps';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('event_step_shown', 'tool_usertours');
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['tourid'])) {
+            throw new \coding_exception('The \'tourid\' value must be set in other.');
+        }
+
+        if (!isset($this->other['stepindex'])) {
+            throw new \coding_exception('The \'stepindex\' value must be set in other.');
+        }
+
+        if (!isset($this->other['pageurl'])) {
+            throw new \coding_exception('The \'pageurl\' value must be set in other.');
+        }
+    }
+
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the information in 'other' to it's new value in the new course.
+     *
+     * Does nothing in the base class except display a debugging message warning
+     * the user that the event does not contain the required functionality to
+     * map this information. For events that do not store any other information this
+     * won't be called, so no debugging message will be displayed.
+     *
+     * @return array an array of other values and their corresponding mapping
+     */
+    public static function get_other_mapping() {
+        return [
+            'pageurl'   => \core\event\base::NOT_MAPPED,
+            'tourid'    => [
+                'db'        => 'tool_usertours_tours',
+                'restore'   => \core\event\base::NOT_MAPPED,
+            ],
+            'stepindex' => \core\event\base::NOT_MAPPED,
+        ];
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the objectid to it's new value in the new course.
+     *
+     * Does nothing in the base class except display a debugging message warning
+     * the user that the event does not contain the required functionality to
+     * map this information. For events that do not store an objectid this won't
+     * be called, so no debugging message will be displayed.
+     *
+     * @return string the name of the restore mapping the objectid links to
+     */
+    public static function get_objectid_mapping() {
+        return [
+            'db'        => 'tool_usertours_steps',
+            'restore'   => \core\event\base::NOT_MAPPED,
+        ];
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '{$this->userid}' has viewed the tour with id " .
+            "'{$this->other['tourid']}' at step index " .
+            "'{$this->other['stepindex']}' (id '{$this->objectid}') on the page with URL " .
+            "'{$this->other['pageurl']}'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return \tool_usertours\helper::get_edit_step_link($this->other['tourid'], $this->objectid);
+    }
+}
diff --git a/admin/tool/usertours/classes/event/tour_ended.php b/admin/tool/usertours/classes/event/tour_ended.php
new file mode 100644 (file)
index 0000000..945d1e4
--- /dev/null
@@ -0,0 +1,145 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The tool_usertours tour_ended event.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The tool_usertours tour_ended event.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int       tourid:     The id of the tour
+ *      - string    pageurl:    The URL of the page viewing the tour
+ * }
+ */
+class tour_ended extends \core\event\base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+        $this->data['objecttable'] = 'tool_usertours_tours';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('event_tour_ended', 'tool_usertours');
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['stepindex'])) {
+            throw new \coding_exception('The \'stepindex\' value must be set in other.');
+        }
+
+        if (!isset($this->other['stepid'])) {
+            throw new \coding_exception('The \'stepid\' value must be set in other.');
+        }
+
+        if (!isset($this->other['pageurl'])) {
+            throw new \coding_exception('The \'pageurl\' value must be set in other.');
+        }
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the information in 'other' to it's new value in the new course.
+     *
+     * Does nothing in the base class except display a debugging message warning
+     * the user that the event does not contain the required functionality to
+     * map this information. For events that do not store any other information this
+     * won't be called, so no debugging message will be displayed.
+     *
+     * @return array an array of other values and their corresponding mapping
+     */
+    public static function get_other_mapping() {
+        return [
+            'stepindex' => \core\event\base::NOT_MAPPED,
+            'stepid'    => [
+                'db'        => 'tool_usertours_steps',
+                'restore'   => \core\event\base::NOT_MAPPED,
+            ],
+            'pageurl'   => \core\event\base::NOT_MAPPED,
+        ];
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the objectid to it's new value in the new course.
+     *
+     * Does nothing in the base class except display a debugging message warning
+     * the user that the event does not contain the required functionality to
+     * map this information. For events that do not store an objectid this won't
+     * be called, so no debugging message will be displayed.
+     *
+     * @return string the name of the restore mapping the objectid links to
+     */
+    public static function get_objectid_mapping() {
+        return [
+            'db'        => 'tool_usertours_tours',
+            'restore'   => \core\event\base::NOT_MAPPED,
+        ];
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '{$this->userid}' has ended the tour with id " .
+            "'{$this->objectid}' at step index " .
+            "'{$this->other['stepindex']}' (id '{$this->other['stepid']}') on the page with URL " .
+            "'{$this->other['pageurl']}'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return \tool_usertours\helper::get_view_tour_link($this->objectid);
+    }
+}
diff --git a/admin/tool/usertours/classes/event/tour_reset.php b/admin/tool/usertours/classes/event/tour_reset.php
new file mode 100644 (file)
index 0000000..bd9e60d
--- /dev/null
@@ -0,0 +1,132 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The tool_usertours tour_reset event.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The tool_usertours tour_reset event.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int       tourid:     The id of the tour
+ *      - string    pageurl:    The URL of the page viewing the tour
+ * }
+ */
+class tour_reset extends \core\event\base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+        $this->data['objecttable'] = 'tool_usertours_tours';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('event_tour_reset', 'tool_usertours');
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['pageurl'])) {
+            throw new \coding_exception('The \'pageurl\' value must be set in other.');
+        }
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the information in 'other' to it's new value in the new course.
+     *
+     * Does nothing in the base class except display a debugging message warning
+     * the user that the event does not contain the required functionality to
+     * map this information. For events that do not store any other information this
+     * won't be called, so no debugging message will be displayed.
+     *
+     * @return array an array of other values and their corresponding mapping
+     */
+    public static function get_other_mapping() {
+        return [
+            'pageurl'   => \core\event\base::NOT_MAPPED,
+        ];
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the objectid to it's new value in the new course.
+     *
+     * Does nothing in the base class except display a debugging message warning
+     * the user that the event does not contain the required functionality to
+     * map this information. For events that do not store an objectid this won't
+     * be called, so no debugging message will be displayed.
+     *
+     * @return string the name of the restore mapping the objectid links to
+     */
+    public static function get_objectid_mapping() {
+        return [
+            'db'        => 'tool_usertours_tours',
+            'restore'   => \core\event\base::NOT_MAPPED,
+        ];
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id " .
+            "'{$this->userid}' has reset the state of tour with id " .
+            "'{$this->objectid}' on the page with URL " .
+            "'{$this->other['pageurl']}'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return \tool_usertours\helper::get_view_tour_link($this->objectid);
+    }
+}
diff --git a/admin/tool/usertours/classes/event/tour_started.php b/admin/tool/usertours/classes/event/tour_started.php
new file mode 100644 (file)
index 0000000..915cb58
--- /dev/null
@@ -0,0 +1,131 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * The tool_usertours tour_started event.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The tool_usertours tour_started event.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int       tourid:     The id of the tour
+ *      - string    pageurl:    The URL of the page viewing the tour
+ * }
+ */
+class tour_started extends \core\event\base {
+
+    /**
+     * Init method.
+     */
+    protected function init() {
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+        $this->data['objecttable'] = 'tool_usertours_tours';
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('event_tour_started', 'tool_usertours');
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (!isset($this->other['pageurl'])) {
+            throw new \coding_exception('The \'pageurl\' value must be set in other.');
+        }
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the information in 'other' to it's new value in the new course.
+     *
+     * Does nothing in the base class except display a debugging message warning
+     * the user that the event does not contain the required functionality to
+     * map this information. For events that do not store any other information this
+     * won't be called, so no debugging message will be displayed.
+     *
+     * @return array an array of other values and their corresponding mapping
+     */
+    public static function get_other_mapping() {
+        return [
+            'pageurl'   => \core\event\base::NOT_MAPPED,
+        ];
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the objectid to it's new value in the new course.
+     *
+     * Does nothing in the base class except display a debugging message warning
+     * the user that the event does not contain the required functionality to
+     * map this information. For events that do not store an objectid this won't
+     * be called, so no debugging message will be displayed.
+     *
+     * @return string the name of the restore mapping the objectid links to
+     */
+    public static function get_objectid_mapping() {
+        return [
+            'db'        => 'tool_usertours_tours',
+            'restore'   => \core\event\base::NOT_MAPPED,
+        ];
+    }
+
+    /**
+     * Returns non-localised event description with id's for admin use only.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '{$this->userid}' " .
+            "has started the tour with id '{$this->objectid}' " .
+            "on the page with URL '{$this->other['pageurl']}'.";
+    }
+
+    /**
+     * Returns relevant URL.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return \tool_usertours\helper::get_view_tour_link($this->objectid);
+    }
+}
diff --git a/admin/tool/usertours/classes/external/tour.php b/admin/tool/usertours/classes/external/tour.php
new file mode 100644 (file)
index 0000000..cac492d
--- /dev/null
@@ -0,0 +1,330 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Web Service functions for steps.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+use external_api;
+use external_function_parameters;
+use external_single_structure;
+use external_multiple_structure;
+use external_value;
+use tool_usertours\tour as tourinstance;
+use tool_usertours\step;
+
+/**
+ * Web Service functions for steps.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tour extends external_api {
+    /**
+     * Fetch the tour configuration for the specified tour.
+     *
+     * @param   int     $tourid     The ID of the tour to fetch.
+     * @param   int     $context    The Context ID of the current page.
+     * @param   string  $pageurl    The path of the current page.
+     * @return  array               As described in fetch_and_start_tour_returns
+     */
+    public static function fetch_and_start_tour($tourid, $context, $pageurl) {
+        global $PAGE;
+
+        $params = self::validate_parameters(self::fetch_and_start_tour_parameters(), [
+                'tourid'    => $tourid,
+                'context'   => $context,
+                'pageurl'   => $pageurl,
+            ]);
+
+        $context = \context_helper::instance_by_id($params['context']);
+        self::validate_context($context);
+
+        $tour = tourinstance::instance($params['tourid']);
+        if (!$tour->should_show_for_user()) {
+            return [];
+        }
+
+        $touroutput = new \tool_usertours\output\tour($tour);
+
+        \tool_usertours\event\tour_started::create([
+            'contextid' => $context->id,
+            'objectid'  => $tourid,
+            'other'     => [
+                'pageurl' => $pageurl,
+            ],
+        ])->trigger();
+
+        return [
+            'tourconfig' => $touroutput->export_for_template($PAGE->get_renderer('core')),
+        ];
+    }
+
+    /**
+     * The parameters for fetch_and_start_tour.
+     *
+     * @return external_function_parameters
+     */
+    public static function fetch_and_start_tour_parameters() {
+        return new external_function_parameters([
+            'tourid'    => new external_value(PARAM_INT, 'Tour ID'),
+            'context'   => new external_value(PARAM_INT, 'Context ID'),
+            'pageurl'   => new external_value(PARAM_URL, 'Page URL'),
+        ]);
+    }
+
+    /**
+     * The return configuration for fetch_and_start_tour.
+     *
+     * @return external_single_structure
+     */
+    public static function fetch_and_start_tour_returns() {
+        return new external_single_structure([
+            'tourconfig'    => new external_single_structure([
+                'name'      => new external_value(PARAM_RAW, 'Tour Name'),
+                'steps'     => new external_multiple_structure(self::step_structure_returns()),
+            ])
+        ]);
+    }
+
+    /**
+     * Reset the specified tour for the current user.
+     *
+     * @param   int     $tourid     The ID of the tour.
+     * @param   int     $context    The Context ID of the current page.
+     * @param   string  $pageurl    The path of the current page requesting the reset.
+     * @return  array               As described in reset_tour_returns
+     */
+    public static function reset_tour($tourid, $context, $pageurl) {
+        $params = self::validate_parameters(self::reset_tour_parameters(), [
+                'tourid'    => $tourid,
+                'context'   => $context,
+                'pageurl'   => $pageurl,
+            ]);
+
+        $context = \context_helper::instance_by_id($params['context']);
+        self::validate_context($context);
+
+        $tour = tourinstance::instance($params['tourid']);
+        $tour->request_user_reset();
+
+        $result = [];
+
+        if ($tourinstance = \tool_usertours\manager::get_matching_tours(new \moodle_url($params['pageurl']))) {
+            if ($tour->get_id() === $tourinstance->get_id()) {
+                $result['startTour'] = $tour->get_id();
+
+                \tool_usertours\event\tour_reset::create([
+                    'contextid' => $context->id,
+                    'objectid'  => $params['tourid'],
+                    'other'     => [
+                        'pageurl'   => $params['pageurl'],
+                    ],
+                ])->trigger();
+
+            }
+        }
+
+        return $result;
+    }
+
+    /**
+     * The parameters for reset_tour.
+     *
+     * @return external_function_parameters
+     */
+    public static function reset_tour_parameters() {
+        return new external_function_parameters([
+            'tourid'    => new external_value(PARAM_INT, 'Tour ID'),
+            'context'   => new external_value(PARAM_INT, 'Context ID'),
+            'pageurl'   => new external_value(PARAM_URL, 'Current page location'),
+        ]);
+    }
+
+    /**
+     * The return configuration for reset_tour.
+     *
+     * @return external_single_structure
+     */
+    public static function reset_tour_returns() {
+        return new external_single_structure([
+            'startTour'     => new external_value(PARAM_INT, 'Tour ID', VALUE_OPTIONAL),
+        ]);
+    }
+
+    /**
+     * Mark the specified tour as completed for the current user.
+     *
+     * @param   int     $tourid     The ID of the tour.
+     * @param   int     $context    The Context ID of the current page.
+     * @param   string  $pageurl    The path of the current page.
+     * @param   int     $stepid     The step id
+     * @param   int     $stepindex  The step index
+     * @return  array               As described in complete_tour_returns
+     */
+    public static function complete_tour($tourid, $context, $pageurl, $stepid, $stepindex) {
+        $params = self::validate_parameters(self::complete_tour_parameters(), [
+                'tourid'    => $tourid,
+                'context'   => $context,
+                'pageurl'   => $pageurl,
+                'stepid'    => $stepid,
+                'stepindex' => $stepindex,
+            ]);
+
+        $context = \context_helper::instance_by_id($params['context']);
+        self::validate_context($context);
+
+        $tour = tourinstance::instance($params['tourid']);
+        $tour->mark_user_completed();
+
+        \tool_usertours\event\tour_ended::create([
+            'contextid' => $context->id,
+            'objectid'  => $params['tourid'],
+            'other'     => [
+                'pageurl'   => $params['pageurl'],
+                'stepid'    => $params['stepid'],
+                'stepindex' => $params['stepindex'],
+            ],
+        ])->trigger();
+
+        return [];
+    }
+
+    /**
+     * The parameters for complete_tour.
+     *
+     * @return external_function_parameters
+     */
+    public static function complete_tour_parameters() {
+        return new external_function_parameters([
+            'tourid'    => new external_value(PARAM_INT, 'Tour ID'),
+            'context'   => new external_value(PARAM_INT, 'Context ID'),
+            'pageurl'   => new external_value(PARAM_LOCALURL, 'Page URL'),
+            'stepid'    => new external_value(PARAM_INT, 'Step ID'),
+            'stepindex' => new external_value(PARAM_INT, 'Step Number'),
+        ]);
+    }
+
+    /**
+     * The return configuration for complete_tour.
+     *
+     * @return external_single_structure
+     */
+    public static function complete_tour_returns() {
+        return new external_single_structure([]);
+    }
+
+    /**
+     * Mark the specified toru step as shown for the current user.
+     *
+     * @param   int     $tourid     The ID of the tour.
+     * @param   int     $context    The Context ID of the current page.
+     * @param   string  $pageurl    The path of the current page.
+     * @param   int     $stepid     The step id
+     * @param   int     $stepindex  The step index
+     * @return  array               As described in complete_tour_returns
+     */
+    public static function step_shown($tourid, $context, $pageurl, $stepid, $stepindex) {
+        $params = self::validate_parameters(self::step_shown_parameters(), [
+                'tourid'    => $tourid,
+                'context'   => $context,
+                'pageurl'   => $pageurl,
+                'stepid'    => $stepid,
+                'stepindex' => $stepindex,
+            ]);
+
+        $context = \context_helper::instance_by_id($params['context']);
+        self::validate_context($context);
+
+        $step = step::instance($params['stepid']);
+        if ($step->get_tourid() !== $params['tourid']) {
+            throw new \moodle_exception('Incorrect tour specified.');
+        }
+
+        \tool_usertours\event\step_shown::create([
+            'contextid' => $context->id,
+            'objectid'  => $params['stepid'],
+
+            'other'     => [
+                'pageurl'   => $params['pageurl'],
+                'tourid'    => $params['tourid'],
+                'stepindex' => $params['stepindex'],
+            ],
+        ])->trigger();
+
+        return [];
+    }
+
+    /**
+     * The parameters for step_shown.
+     *
+     * @return external_function_parameters
+     */
+    public static function step_shown_parameters() {
+        return new external_function_parameters([
+            'tourid'    => new external_value(PARAM_INT, 'Tour ID'),
+            'context'   => new external_value(PARAM_INT, 'Context ID'),
+            'pageurl'   => new external_value(PARAM_URL, 'Page URL'),
+            'stepid'    => new external_value(PARAM_INT, 'Step ID'),
+            'stepindex' => new external_value(PARAM_INT, 'Step Number'),
+        ]);
+    }
+
+    /**
+     * The return configuration for step_shown.
+     *
+     * @return external_single_structure
+     */
+    public static function step_shown_returns() {
+        return new external_single_structure([]);
+    }
+
+    /**
+     * The standard return structure for a step.
+     *
+     * @return external_multiple_structure
+     */
+    public static function step_structure_returns() {
+        return new external_single_structure([
+            'title'             => new external_value(PARAM_RAW,
+                    'Step Title'),
+            'content'           => new external_value(PARAM_RAW,
+                    'Step Content'),
+            'element'           => new external_value(PARAM_TEXT,
+                    'Step Target'),
+            'placement'         => new external_value(PARAM_TEXT,
+                    'Step Placement'),
+            'delay'             => new external_value(PARAM_INT,
+                    'Delay before showing the step (ms)', VALUE_OPTIONAL),
+            'backdrop'          => new external_value(PARAM_BOOL,
+                    'Whether a backdrop should be used', VALUE_OPTIONAL),
+            'reflex'            => new external_value(PARAM_BOOL,
+                    'Whether to move to the next step when the target element is clicked', VALUE_OPTIONAL),
+            'orphan'            => new external_value(PARAM_BOOL,
+                    'Whether to display the step even if it could not be found', VALUE_OPTIONAL),
+            'stepid'            => new external_value(PARAM_INT,
+                    'The actual ID of the step', VALUE_OPTIONAL),
+        ]);
+    }
+}
diff --git a/admin/tool/usertours/classes/helper.php b/admin/tool/usertours/classes/helper.php
new file mode 100644 (file)
index 0000000..d27db08
--- /dev/null
@@ -0,0 +1,523 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Tour helper.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Tour helper.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class helper {
+
+    /**
+     * @var MOVE_UP
+     */
+    const MOVE_UP = -1;
+
+    /**
+     * @var MOVE_DOWN
+     */
+    const MOVE_DOWN = 1;
+
+    /**
+     * Get the link to edit the step.
+     *
+     * If no stepid is specified, then a link to create a new step is provided. The $targettype must be specified in this case.
+     *
+     * @param   int     $tourid     The tour that the step belongs to.
+     * @param   int     $stepid     The step ID.
+     * @param   int     $targettype The type of step.
+     *
+     * @return moodle_url
+     */
+    public static function get_edit_step_link($tourid, $stepid = null, $targettype = null) {
+        $link = new \moodle_url('/admin/tool/usertours/configure.php');
+
+        if ($stepid) {
+            $link->param('action', manager::ACTION_EDITSTEP);
+            $link->param('id', $stepid);
+        } else {
+            $link->param('action', manager::ACTION_NEWSTEP);
+            $link->param('tourid', $tourid);
+        }
+
+        return $link;
+    }
+
+    /**
+     * Get the link to move the tour.
+     *
+     * @param   int     $tourid     The tour ID.
+     * @param   int     $direction  The direction to move in
+     *
+     * @return moodle_url
+     */
+    public static function get_move_tour_link($tourid, $direction = self::MOVE_DOWN) {
+        $link = new \moodle_url('/admin/tool/usertours/configure.php');
+
+        $link->param('action', manager::ACTION_MOVETOUR);
+        $link->param('id', $tourid);
+        $link->param('direction', $direction);
+        $link->param('sesskey', sesskey());
+
+        return $link;
+    }
+
+    /**
+     * Get the link to move the step.
+     *
+     * @param   int     $stepid     The step ID.
+     * @param   int     $direction  The direction to move in
+     *
+     * @return moodle_url
+     */
+    public static function get_move_step_link($stepid, $direction = self::MOVE_DOWN) {
+        $link = new \moodle_url('/admin/tool/usertours/configure.php');
+
+        $link->param('action', manager::ACTION_MOVESTEP);
+        $link->param('id', $stepid);
+        $link->param('direction', $direction);
+        $link->param('sesskey', sesskey());
+
+        return $link;
+    }
+
+    /**
+     * Get the link ot create a new step.
+     *
+     * @param   int         $tourid     The ID of the tour to attach this step to.
+     * @param   int         $targettype The type of target.
+     *
+     * @return  moodle_url              The required URL.
+     */
+    public static function get_new_step_link($tourid, $targettype = null) {
+        $link = new \moodle_url('/admin/tool/usertours/configure.php');
+        $link->param('action', manager::ACTION_NEWSTEP);
+        $link->param('tourid', $tourid);
+        $link->param('targettype', $targettype);
+
+        return $link;
+    }
+
+    /**
+     * Get the link used to view the tour.
+     *
+     * @param   int         $tourid     The ID of the tour to display.
+     * @return  moodle_url              The URL.
+     */
+    public static function get_view_tour_link($tourid) {
+        return new \moodle_url('/admin/tool/usertours/configure.php', [
+                'id'        => $tourid,
+                'action'    => manager::ACTION_VIEWTOUR,
+            ]);
+    }
+
+    /**
+     * Get the link used to reset the tour state for all users.
+     *
+     * @param   int         $tourid     The ID of the tour to display.
+     * @return  moodle_url              The URL.
+     */
+    public static function get_reset_tour_for_all_link($tourid) {
+        return new \moodle_url('/admin/tool/usertours/configure.php', [
+                'id'        => $tourid,
+                'action'    => manager::ACTION_RESETFORALL,
+                'sesskey'   => sesskey(),
+            ]);
+    }
+
+    /**
+     * Get the link used to edit the tour.
+     *
+     * @param   int         $tourid     The ID of the tour to edit.
+     * @return  moodle_url              The URL.
+     */
+    public static function get_edit_tour_link($tourid = null) {
+        $link = new \moodle_url('/admin/tool/usertours/configure.php');
+
+        if ($tourid) {
+            $link->param('action', manager::ACTION_EDITTOUR);
+            $link->param('id', $tourid);
+        } else {
+            $link->param('action', manager::ACTION_NEWTOUR);
+        }
+
+        return $link;
+    }
+
+    /**
+     * Get the link used to import the tour.
+     *
+     * @return  moodle_url              The URL.
+     */
+    public static function get_import_tour_link() {
+        $link = new \moodle_url('/admin/tool/usertours/configure.php', [
+                'action'    => manager::ACTION_IMPORTTOUR,
+            ]);
+
+        return $link;
+    }
+
+    /**
+     * Get the link used to export the tour.
+     *
+     * @param   int         $tourid     The ID of the tour to export.
+     * @return  moodle_url              The URL.
+     */
+    public static function get_export_tour_link($tourid) {
+        $link = new \moodle_url('/admin/tool/usertours/configure.php', [
+                'action'    => manager::ACTION_EXPORTTOUR,
+                'id'        => $tourid,
+            ]);
+
+        return $link;
+    }
+
+    /**
+     * Get the link used to delete the tour.
+     *
+     * @param   int         $tourid     The ID of the tour to delete.
+     * @return  moodle_url              The URL.
+     */
+    public static function get_delete_tour_link($tourid) {
+        return new \moodle_url('/admin/tool/usertours/configure.php', [
+                'id'        => $tourid,
+                'action'    => manager::ACTION_DELETETOUR,
+                'sesskey'   => sesskey(),
+            ]);
+    }
+
+    /**
+     * Get the link for listing tours.
+     *
+     * @return  moodle_url              The URL.
+     */
+    public static function get_list_tour_link() {
+        $link = new \moodle_url('/admin/tool/usertours/configure.php');
+        $link->param('action', manager::ACTION_LISTTOURS);
+
+        return $link;
+    }
+
+    /**
+     * Get a filler icon for display in the actions column of a table.
+     *
+     * @param   string      $url            The URL for the icon.
+     * @param   string      $icon           The icon identifier.
+     * @param   string      $alt            The alt text for the icon.
+     * @param   string      $iconcomponent  The icon component.
+     * @param   array       $options        Display options.
+     * @return  string
+     */
+    public static function format_icon_link($url, $icon, $alt, $iconcomponent = 'moodle', $options = array()) {
+        global $OUTPUT;
+
+        return $OUTPUT->action_icon(
+                $url,
+                new \pix_icon($icon, $alt, $iconcomponent, [
+                        'title' => $alt,
+                    ]),
+                null,
+                $options
+                );
+
+    }
+
+    /**
+     * Get a filler icon for display in the actions column of a table.
+     *
+     * @param   array       $options        Display options.
+     * @return  string
+     */
+    public static function get_filler_icon($options = array()) {
+        global $OUTPUT;
+
+        return \html_writer::span(
+            $OUTPUT->pix_icon('t/filler', '', 'tool_usertours', $options),
+            'action-icon'
+        );
+    }
+
+    /**
+     * Get the link for deleting steps.
+     *
+     * @param   int         $stepid     The ID of the step to display.
+     * @return  moodle_url              The URL.
+     */
+    public static function get_delete_step_link($stepid) {
+        return new \moodle_url('/admin/tool/usertours/configure.php', [
+                'action'    => manager::ACTION_DELETESTEP,
+                'id'        => $stepid,
+                'sesskey'   => sesskey(),
+            ]);
+    }
+
+    /**
+     * Render the inplace editable used to edit the tour name.
+     *
+     * @param   tour        $tour       The tour to edit.
+     * @return  string
+     */
+    public static function render_tourname_inplace_editable(tour $tour) {
+        return new \core\output\inplace_editable(
+                'tool_usertours',
+                'tourname',
+                $tour->get_id(),
+                true,
+                \html_writer::link(
+                    $tour->get_view_link(),
+                    $tour->get_name()
+                ),
+                $tour->get_name()
+            );
+    }
+
+    /**
+     * Render the inplace editable used to edit the tour description.
+     *
+     * @param   tour        $tour       The tour to edit.
+     * @return  string
+     */
+    public static function render_tourdescription_inplace_editable(tour $tour) {
+        return new \core\output\inplace_editable(
+                'tool_usertours',
+                'tourdescription',
+                $tour->get_id(),
+                true,
+                $tour->get_description(),
+                $tour->get_description()
+            );
+    }
+
+    /**
+     * Render the inplace editable used to edit the tour enable state.
+     *
+     * @param   tour        $tour       The tour to edit.
+     * @return  string
+     */
+    public static function render_tourenabled_inplace_editable(tour $tour) {
+        global $OUTPUT;
+
+        if ($tour->is_enabled()) {
+            $icon = 't/hide';
+            $alt = get_string('disable');
+            $value = 1;
+        } else {
+            $icon = 't/show';
+            $alt = get_string('enable');
+            $value = 0;
+        }
+
+        $editable = new \core\output\inplace_editable(
+                'tool_usertours',
+                'tourenabled',
+                $tour->get_id(),
+                true,
+                $OUTPUT->pix_icon($icon, $alt, 'moodle', [
+                        'title' => $alt,
+                    ]),
+                $value
+            );
+
+        $editable->set_type_toggle();
+        return $editable;
+    }
+
+    /**
+     * Render the inplace editable used to edit the step name.
+     *
+     * @param   step        $step       The step to edit.
+     * @return  string
+     */
+    public static function render_stepname_inplace_editable(step $step) {
+        return new \core\output\inplace_editable(
+                'tool_usertours',
+                'stepname',
+                $step->get_id(),
+                true,
+                \html_writer::link(
+                    $step->get_edit_link(),
+                    $step->get_title()
+                ),
+                $step->get_title(false)
+            );
+    }
+
+    /**
+     * Get all of the tours.
+     *
+     * @return  stdClass[]
+     */
+    public static function get_tours() {
+        global $DB;
+
+        $tours = $DB->get_records('tool_usertours_tours', array(), 'sortorder ASC');
+        $return = [];
+        foreach ($tours as $tour) {
+            $return[$tour->id] = tour::load_from_record($tour);
+        }
+        return $return;
+    }
+
+    /**
+     * Get the specified tour.
+     *
+     * @param   int         $tourid     The tour that the step belongs to.
+     * @return  stdClass
+     */
+    public static function get_tour($tourid) {
+        return tour::instance($tourid);
+    }
+
+    /**
+     * Fetch the tour with the specified sortorder.
+     *
+     * @param   int         $sortorder  The sortorder of the tour.
+     * @return  tour
+     */
+    public static function get_tour_from_sortorder($sortorder) {
+        global $DB;
+
+        $tour = $DB->get_record('tool_usertours_tours', array('sortorder' => $sortorder));
+        return tour::load_from_record($tour);
+    }
+
+    /**
+     * Return the count of all tours.
+     *
+     * @return  int
+     */
+    public static function count_tours() {
+        global $DB;
+
+        return $DB->count_records('tool_usertours_tours');
+    }
+
+    /**
+     * Reset the sortorder for all tours.
+     */
+    public static function reset_tour_sortorder() {
+        global $DB;
+        $tours = $DB->get_records('tool_usertours_tours', null, 'sortorder ASC, pathmatch DESC', 'id, sortorder');
+
+        $index = 0;
+        foreach ($tours as $tour) {
+            if ($tour->sortorder != $index) {
+                $DB->set_field('tool_usertours_tours', 'sortorder', $index, array('id' => $tour->id));
+            }
+            $index++;
+        }
+    }
+
+
+    /**
+     * Get all of the steps in the tour.
+     *
+     * @param   int         $tourid     The tour that the step belongs to.
+     * @return  stdClass[]
+     */
+    public static function get_steps($tourid) {
+        global $DB;
+
+        $order = 'sortorder ASC';
+
+        $steps = $DB->get_records('tool_usertours_steps', array('tourid' => $tourid), $order);
+        $return = [];
+        foreach ($steps as $step) {
+            $return[$step->id] = step::load_from_record($step);
+        }
+        return $return;
+    }
+
+    /**
+     * Fetch the specified step.
+     *
+     * @param   int         $stepid     The id of the step to fetch.
+     * @return  step
+     */
+    public static function get_step($stepid) {
+        return step::instance($stepid);
+    }
+
+    /**
+     * Fetch the step with the specified sortorder.
+     *
+     * @param   int         $tourid     The tour that the step belongs to.
+     * @param   int         $sortorder  The sortorder of the step.
+     * @return  step
+     */
+    public static function get_step_from_sortorder($tourid, $sortorder) {
+        global $DB;
+
+        $step = $DB->get_record('tool_usertours_steps', array('tourid' => $tourid, 'sortorder' => $sortorder));
+        return step::load_from_record($step);
+    }
+
+    /**
+     * Handle addition of the tour into the current page.
+     */
+    public static function bootstrap() {
+        global $PAGE;
+
+        if ($tour = manager::get_current_tour()) {
+            $PAGE->requires->js_call_amd('tool_usertours/usertours', 'init', [
+                    $tour->get_id(),
+                    $tour->should_show_for_user(),
+                    $PAGE->context->id,
+                ]);
+        }
+    }
+
+    /**
+     * Add the reset link to the current page.
+     */
+    public static function bootstrap_reset() {
+        if (manager::get_current_tour()) {
+            echo \html_writer::link('', get_string('resettouronpage', 'tool_usertours'), [
+                    'data-action'   => 'tool_usertours/resetpagetour',
+                ]);
+        }
+    }
+
+    /**
+     * Get a list of all possible filters.
+     *
+     * @return  array
+     */
+    public static function get_all_filters() {
+        $filters = \core_component::get_component_classes_in_namespace('tool_usertours', 'local\filter');
+        $filters = array_keys($filters);
+
+        $filters = array_filter($filters, function($filterclass) {
+            $rc = new \ReflectionClass($filterclass);
+            return $rc->isInstantiable();
+        });
+
+        return $filters;
+    }
+}
diff --git a/admin/tool/usertours/classes/local/filter/base.php b/admin/tool/usertours/classes/local/filter/base.php
new file mode 100644 (file)
index 0000000..ff1de44
--- /dev/null
@@ -0,0 +1,135 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Filter base.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\filter;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\tour;
+use context;
+
+/**
+ * Filter base.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class base {
+    /**
+     * Any Value.
+     */
+    const ANYVALUE = '__ANYVALUE__';
+
+    /**
+     * The name of the filter.
+     *
+     * @return  string
+     */
+    public static function get_filter_name() {
+        throw new \coding_exception('get_filter_name() must be defined');
+    }
+
+    /**
+     * Retrieve the list of available filter options.
+     *
+     * @return  array                   An array whose keys are the valid options
+     */
+    public static function get_filter_options() {
+        return [];
+    }
+
+    /**
+     * Check whether the filter matches the specified tour and/or context.
+     *
+     * @param   tour        $tour       The tour to check
+     * @param   context     $context    The context to check
+     * @return  boolean
+     */
+    public static function filter_matches(tour $tour, context $context) {
+        return true;
+    }
+
+    /**
+     * Add the form elements for the filter to the supplied form.
+     *
+     * @param   MoodleQuickForm $mform      The form to add filter settings to.
+     */
+    public static function add_filter_to_form(\MoodleQuickForm &$mform) {
+        $options = [
+            static::ANYVALUE   => get_string('all'),
+        ];
+        $options += static::get_filter_options();
+
+        $filtername = static::get_filter_name();
+        $key = "filter_{$filtername}";
+
+        $mform->addElement('select', $key, get_string($key, 'tool_usertours'), $options, [
+                'multiple' => true,
+            ]);
+        $mform->setDefault($key, static::ANYVALUE);
+        $mform->addHelpButton($key, $key, 'tool_usertours');
+    }
+
+    /**
+     * Prepare the filter values for the form.
+     *
+     * @param   tour            $tour       The tour to prepare values from
+     * @param   stdClass        $data       The data value
+     * @return  stdClass
+     */
+    public static function prepare_filter_values_for_form(tour $tour, \stdClass $data) {
+        $filtername = static::get_filter_name();
+
+        $key = "filter_{$filtername}";
+        $values = $tour->get_filter_values($filtername);
+        if (empty($values)) {
+            $values = static::ANYVALUE;
+        }
+        $data->$key = $values;
+
+        return $data;
+    }
+
+    /**
+     * Save the filter values from the form to the tour.
+     *
+     * @param   tour            $tour       The tour to save values to
+     * @param   stdClass        $data       The data submitted in the form
+     */
+    public static function save_filter_values_from_form(tour $tour, \stdClass $data) {
+        $filtername = static::get_filter_name();
+
+        $key = "filter_{$filtername}";
+
+        $newvalue = $data->$key;
+        foreach ($data->$key as $value) {
+            if ($value === static::ANYVALUE) {
+                $newvalue = [];
+                break;
+            }
+        }
+
+        $tour->set_filter_values($filtername, $newvalue);
+    }
+}
diff --git a/admin/tool/usertours/classes/local/filter/role.php b/admin/tool/usertours/classes/local/filter/role.php
new file mode 100644 (file)
index 0000000..96bc6ea
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Theme filter.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\filter;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\tour;
+use context;
+
+/**
+ * Theme filter.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class role extends base {
+    /**
+     * The name of the filter.
+     *
+     * @return  string
+     */
+    public static function get_filter_name() {
+        return 'role';
+    }
+
+    /**
+     * Retrieve the list of available filter options.
+     *
+     * @return  array                   An array whose keys are the valid options
+     *                                  And whose values are the values to display
+     */
+    public static function get_filter_options() {
+        return role_get_names(null, ROLENAME_ALIAS, true);
+    }
+
+    /**
+     * Check whether the filter matches the specified tour and/or context.
+     *
+     * @param   tour        $tour       The tour to check
+     * @param   context     $context    The context to check
+     * @return  boolean
+     */
+    public static function filter_matches(tour $tour, context $context) {
+        global $USER;
+
+        $values = $tour->get_filter_values(self::get_filter_name());
+
+        if (empty($values)) {
+            // There are no values configured.
+            // No values means all.
+            return true;
+        }
+
+        if (is_siteadmin()) {
+            return true;
+        }
+
+        // Presence within the array is sufficient. Ignore any value.
+        $values = array_flip($values);
+
+        $cache = \cache::make_from_params(\cache_store::MODE_REQUEST, 'tool_usertours', 'filter_role');
+        $cachekey = "{$USER->id}_{$context->id}";
+        $userroles = $cache->get($cachekey);
+        if ($userroles === false) {
+            $userroles = get_user_roles_with_special($context);
+            $cache->set($cachekey, $userroles);
+        }
+
+        foreach ($userroles as $role) {
+            if (isset($values[$role->roleid])) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}
diff --git a/admin/tool/usertours/classes/local/filter/theme.php b/admin/tool/usertours/classes/local/filter/theme.php
new file mode 100644 (file)
index 0000000..786684c
--- /dev/null
@@ -0,0 +1,102 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Theme filter.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\filter;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\tour;
+use context;
+
+/**
+ * Theme filter.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class theme extends base {
+    /**
+     * The name of the filter.
+     *
+     * @return  string
+     */
+    public static function get_filter_name() {
+        return 'theme';
+    }
+
+    /**
+     * Retrieve the list of available filter options.
+     *
+     * @return  array                   An array whose keys are the valid options
+     *                                  And whose values are the values to display
+     */
+    public static function get_filter_options() {
+        $manager = \core_plugin_manager::instance();
+        $themes = $manager->get_installed_plugins('theme');
+
+        $options = [];
+        foreach (array_keys($themes) as $themename) {
+            try {
+                $theme = \theme_config::load($themename);
+            } catch (Exception $e) {
+                // Bad theme, just skip it for now.
+                continue;
+            }
+            if ($themename !== $theme->name) {
+                // Obsoleted or broken theme, just skip for now.
+                continue;
+            }
+            if ($theme->hidefromselector) {
+                // The theme doesn't want to be shown in the theme selector and as theme
+                // designer mode is switched off we will respect that decision.
+                continue;
+            }
+            $options[$theme->name] = get_string('pluginname', "theme_{$theme->name}");
+        }
+        return $options;
+    }
+
+    /**
+     * Check whether the filter matches the specified tour and/or context.
+     *
+     * @param   tour        $tour       The tour to check
+     * @param   context     $context    The context to check
+     * @return  boolean
+     */
+    public static function filter_matches(tour $tour, context $context) {
+        global $PAGE;
+
+        $values = $tour->get_filter_values('theme');
+
+        if (empty($values)) {
+            // There are no values configured.
+            // No values means all.
+            return true;
+        }
+
+        // Presence within the array is sufficient. Ignore any value.
+        $values = array_flip($values);
+        return isset($values[$PAGE->theme->name]);
+    }
+}
diff --git a/admin/tool/usertours/classes/local/forms/editstep.php b/admin/tool/usertours/classes/local/forms/editstep.php
new file mode 100644 (file)
index 0000000..5610efc
--- /dev/null
@@ -0,0 +1,96 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Form for editing steps.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\forms;
+
+defined('MOODLE_INTERNAL') || die('Direct access to this script is forbidden.');
+
+require_once($CFG->libdir . '/formslib.php');
+
+/**
+ * Form for editing steps.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class editstep extends \moodleform {
+    /**
+     * @var tool_usertours\step $step
+     */
+    protected $step;
+
+    /**
+     * Create the edit step form.
+     *
+     * @param   string      $target     The target of the form.
+     * @param   step        $step       The step being editted.
+     */
+    public function __construct($target, \tool_usertours\step $step) {
+        $this->step = $step;
+
+        parent::__construct($target);
+    }
+
+    /**
+     * Form definition.
+     */
+    public function definition() {
+        $mform = $this->_form;
+
+        $types = [];
+        foreach (\tool_usertours\target::get_target_types() as $value => $type) {
+            $types[$value] = get_string('target_' . $type, 'tool_usertours');
+        }
+        $mform->addElement('select', 'targettype', get_string('targettype', 'tool_usertours'), $types);
+
+        // The target configuration.
+        foreach (\tool_usertours\target::get_target_types() as $value => $type) {
+            $targetclass = \tool_usertours\target::get_classname($type);
+            $targetclass::add_config_to_form($mform);
+        }
+
+        // Title of the step.
+        $mform->addElement('textarea', 'title', get_string('title', 'tool_usertours'));
+        $mform->addRule('title', get_string('required'), 'required', null, 'client');
+        $mform->setType('title', PARAM_TEXT);
+        $mform->addHelpButton('title', 'title', 'tool_usertours');
+
+        $mform->addElement('textarea', 'content', get_string('content', 'tool_usertours'));
+        $mform->addRule('content', get_string('required'), 'required', null, 'client');
+        $mform->setType('content', PARAM_RAW);
+        $mform->addHelpButton('content', 'content', 'tool_usertours');
+
+        // Add the step configuration.
+        // All step configuration is defined in the step.
+        $this->step->add_config_to_form($mform);
+
+        // And apply any form constraints.
+        foreach (\tool_usertours\target::get_target_types() as $value => $type) {
+            $targetclass = \tool_usertours\target::get_classname($type);
+            $targetclass::add_disabled_constraints_to_form($mform);
+        }
+
+        $this->add_action_buttons();
+    }
+}
diff --git a/admin/tool/usertours/classes/local/forms/edittour.php b/admin/tool/usertours/classes/local/forms/edittour.php
new file mode 100644 (file)
index 0000000..3bb71ae
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Form for editing tours.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\forms;
+
+defined('MOODLE_INTERNAL') || die('Direct access to this script is forbidden.');
+
+require_once($CFG->libdir . '/formslib.php');
+
+use \tool_usertours\helper;
+
+/**
+ * Form for editing tours.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class edittour extends \moodleform {
+    /**
+     * @var tool_usertours\tour $tour
+     */
+    protected $tour;
+
+    /**
+     * Create the edit tour form.
+     *
+     * @param   tour        $tour       The tour being editted.
+     */
+    public function __construct(\tool_usertours\tour $tour) {
+        $this->tour = $tour;
+
+        parent::__construct($tour->get_edit_link());
+    }
+
+    /**
+     * Form definition.
+     */
+    public function definition() {
+        $mform = $this->_form;
+
+        // ID of existing tour.
+        $mform->addElement('hidden', 'id');
+        $mform->setType('id', PARAM_INT);
+
+        // Name of the tour.
+        $mform->addElement('text', 'name', get_string('name', 'tool_usertours'));
+        $mform->addRule('name', get_string('required'), 'required', null, 'client');
+        $mform->setType('name', PARAM_TEXT);
+
+        // Admin-only descriptions.
+        $mform->addElement('textarea', 'description', get_string('description', 'tool_usertours'));
+        $mform->setType('description', PARAM_RAW);
+
+        // Application.
+        $mform->addElement('text', 'pathmatch', get_string('pathmatch', 'tool_usertours'));
+        $mform->setType('pathmatch', PARAM_RAW);
+        $mform->addHelpButton('pathmatch', 'pathmatch', 'tool_usertours');
+
+        $mform->addElement('checkbox', 'enabled', get_string('enabled', 'tool_usertours'));
+
+        // Configuration.
+        $this->tour->add_config_to_form($mform);
+
+        // Filters.
+        $mform->addElement('header', 'filters', get_string('filter_header', 'tool_usertours'));
+        $mform->addElement('static', 'filterhelp', '', get_string('filter_help', 'tool_usertours'));
+
+        foreach (helper::get_all_filters() as $filterclass) {
+            $filterclass::add_filter_to_form($mform);
+        }
+
+        $this->add_action_buttons();
+    }
+}
diff --git a/admin/tool/usertours/classes/local/forms/importtour.php b/admin/tool/usertours/classes/local/forms/importtour.php
new file mode 100644 (file)
index 0000000..ccb82db
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Form for editing tours.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\forms;
+
+defined('MOODLE_INTERNAL') || die('Direct access to this script is forbidden.');
+
+require_once($CFG->libdir . '/formslib.php');
+
+/**
+ * Form for importing tours.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class importtour extends \moodleform {
+    /**
+     * Create the import tour form.
+     */
+    public function __construct() {
+        parent::__construct(\tool_usertours\helper::get_import_tour_link());
+    }
+
+    /**
+     * Form definition.
+     */
+    public function definition() {
+        $mform = $this->_form;
+
+        $mform->addElement('filepicker', 'tourconfig', get_string('tourconfig', 'tool_usertours'));
+        $mform->addRule('tourconfig', null, 'required');
+
+        $this->add_action_buttons();
+    }
+}
diff --git a/admin/tool/usertours/classes/local/table/step_list.php b/admin/tool/usertours/classes/local/table/step_list.php
new file mode 100644 (file)
index 0000000..2afafb8
--- /dev/null
@@ -0,0 +1,143 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Table to show the list of steps in a tour.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\table;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\helper;
+use tool_usertours\tour;
+use tool_usertours\step;
+
+require_once($CFG->libdir . '/tablelib.php');
+
+/**
+ * Table to show the list of steps in a tour.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class step_list extends \flexible_table {
+
+    /**
+     * @var     int     $tourid     The id of the tour.
+     */
+    protected $tourid;
+
+    /**
+     * Construct the table for the specified tour ID.
+     *
+     * @param   int     $tourid     The id of the tour.
+     */
+    public function __construct($tourid) {
+        parent::__construct('steps');
+        $this->tourid = $tourid;
+
+        $baseurl = new \moodle_url('/tool/usertours/configure.php', array(
+                'id' => $tourid,
+            ));
+        $this->define_baseurl($baseurl);
+
+        // Column definition.
+        $this->define_columns(array(
+            'title',
+            'content',
+            'target',
+            'actions',
+        ));
+
+        $this->define_headers(array(
+            get_string('title',   'tool_usertours'),
+            get_string('content', 'tool_usertours'),
+            get_string('target',  'tool_usertours'),
+            get_string('actions', 'tool_usertours'),
+        ));
+
+        $this->set_attribute('class', 'admintable generaltable steptable');
+        $this->setup();
+    }
+
+    /**
+     * Format the current row's title column.
+     *
+     * @param   step    $step       The step for this row.
+     * @return  string
+     */
+    protected function col_title(step $step) {
+        global $OUTPUT;
+        return $OUTPUT->render(helper::render_stepname_inplace_editable($step));
+    }
+
+    /**
+     * Format the current row's content column.
+     *
+     * @param   step    $step       The step for this row.
+     * @return  string
+     */
+    protected function col_content(step $step) {
+        return $step->get_content(false);
+    }
+
+    /**
+     * Format the current row's target column.
+     *
+     * @param   step    $step       The step for this row.
+     * @return  string
+     */
+    protected function col_target(step $step) {
+        return $step->get_target()->get_displayname();
+    }
+
+    /**
+     * Format the current row's actions column.
+     *
+     * @param   step    $step       The step for this row.
+     * @return  string
+     */
+    protected function col_actions(step $step) {
+        $actions = [];
+
+        if ($step->is_first_step()) {
+            $actions[] = helper::get_filler_icon();
+        } else {
+            $actions[] = helper::format_icon_link($step->get_moveup_link(), 't/up', get_string('movestepup', 'tool_usertours'));
+        }
+
+        if ($step->is_last_step()) {
+            $actions[] = helper::get_filler_icon();
+        } else {
+            $actions[] = helper::format_icon_link($step->get_movedown_link(), 't/down',
+                    get_string('movestepdown', 'tool_usertours'));
+        }
+
+        $actions[] = helper::format_icon_link($step->get_edit_link(), 't/edit', get_string('edit'));
+
+        $actions[] = helper::format_icon_link($step->get_delete_link(), 't/delete', get_string('delete'), 'moodle', [
+            'data-action'   => 'delete',
+            'data-id'       => $step->get_id(),
+        ]);
+
+        return implode('&nbsp;', $actions);
+    }
+}
diff --git a/admin/tool/usertours/classes/local/table/tour_list.php b/admin/tool/usertours/classes/local/table/tour_list.php
new file mode 100644 (file)
index 0000000..10de685
--- /dev/null
@@ -0,0 +1,150 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Table to show the list of tours.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\table;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\helper;
+use tool_usertours\tour;
+
+require_once($CFG->libdir . '/tablelib.php');
+
+/**
+ * Table to show the list of tours.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tour_list extends \flexible_table {
+    /**
+     * Construct the tour table.
+     */
+    public function __construct() {
+        parent::__construct('tours');
+
+        $baseurl = new \moodle_url('/tool/usertours/configure.php');
+        $this->define_baseurl($baseurl);
+
+        // Column definition.
+        $this->define_columns(array(
+            'name',
+            'description',
+            'appliesto',
+            'enabled',
+            'actions',
+        ));
+
+        $this->define_headers(array(
+            get_string('name', 'tool_usertours'),
+            get_string('description', 'tool_usertours'),
+            get_string('appliesto', 'tool_usertours'),
+            get_string('enabled', 'tool_usertours'),
+            get_string('actions', 'tool_usertours'),
+        ));
+
+        $this->set_attribute('class', 'admintable generaltable');
+        $this->setup();
+
+        $this->tourcount = helper::count_tours();
+    }
+
+    /**
+     * Format the current row's name column.
+     *
+     * @param   tour    $tour       The tour for this row.
+     * @return  string
+     */
+    protected function col_name(tour $tour) {
+        global $OUTPUT;
+        return $OUTPUT->render(helper::render_tourname_inplace_editable($tour));
+    }
+
+    /**
+     * Format the current row's description column.
+     *
+     * @param   tour    $tour       The tour for this row.
+     * @return  string
+     */
+    protected function col_description(tour $tour) {
+        global $OUTPUT;
+        return $OUTPUT->render(helper::render_tourdescription_inplace_editable($tour));
+    }
+
+    /**
+     * Format the current row's appliesto column.
+     *
+     * @param   tour    $tour       The tour for this row.
+     * @return  string
+     */
+    protected function col_appliesto(tour $tour) {
+        return $tour->get_pathmatch();
+    }
+
+    /**
+     * Format the current row's enabled column.
+     *
+     * @param   tour    $tour       The tour for this row.
+     * @return  string
+     */
+    protected function col_enabled(tour $tour) {
+        global $OUTPUT;
+        return $OUTPUT->render(helper::render_tourenabled_inplace_editable($tour));
+    }
+
+    /**
+     * Format the current row's actions column.
+     *
+     * @param   tour    $tour       The tour for this row.
+     * @return  string
+     */
+    protected function col_actions(tour $tour) {
+        $actions = [];
+
+        if ($tour->is_first_tour()) {
+            $actions[] = helper::get_filler_icon();
+        } else {
+            $actions[] = helper::format_icon_link($tour->get_moveup_link(), 't/up',
+                    get_string('movetourup', 'tool_usertours'));
+        }
+
+        if ($tour->is_last_tour($this->tourcount)) {
+            $actions[] = helper::get_filler_icon();
+        } else {
+            $actions[] = helper::format_icon_link($tour->get_movedown_link(), 't/down',
+                    get_string('movetourdown', 'tool_usertours'));
+        }
+
+        $actions[] = helper::format_icon_link($tour->get_view_link(), 't/viewdetails', get_string('view'));
+        $actions[] = helper::format_icon_link($tour->get_edit_link(), 't/edit', get_string('edit'));
+        $actions[] = helper::format_icon_link($tour->get_export_link(), 't/export',
+                get_string('exporttour', 'tool_usertours'), 'tool_usertours');
+        $actions[] = helper::format_icon_link($tour->get_delete_link(), 't/delete', get_string('delete'), null, [
+                'data-action'   => 'delete',
+                'data-id'       => $tour->get_id(),
+            ]);
+
+        return implode('&nbsp;', $actions);
+    }
+}
diff --git a/admin/tool/usertours/classes/local/target/base.php b/admin/tool/usertours/classes/local/target/base.php
new file mode 100644 (file)
index 0000000..865173a
--- /dev/null
@@ -0,0 +1,119 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Target base.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\target;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\step;
+
+/**
+ * Target base.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class base {
+    /**
+     * @var     step        $step           The step being targetted.
+     */
+    protected $step;
+
+    /**
+     * @var     array       $forcedsettings The settings forced by this type.
+     */
+    protected static $forcedsettings = [];
+
+    /**
+     * Create the target type.
+     *
+     * @param   step        $step       The step being targetted.
+     */
+    public function __construct(step $step) {
+        $this->step = $step;
+    }
+
+    /**
+     * Convert the target value to a valid CSS selector for use in the
+     * output configuration.
+     *
+     * @return string
+     */
+    abstract public function convert_to_css();
+
+    /**
+     * Convert the step target to a friendly name for use in the UI.
+     *
+     * @return string
+     */
+    abstract public function get_displayname();
+
+    /**
+     * Add the target type configuration to the form.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     */
+    public static function add_config_to_form(\MoodleQuickForm $mform) {
+    }
+
+    /**
+     * Add the disabledIf values.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     */
+    public static function add_disabled_constraints_to_form(\MoodleQuickForm $mform) {
+    }
+
+    /**
+     * Whether the specified step setting is forced by this target type.
+     *
+     * @param   string          $key        The name of the key to check.
+     * @return  boolean
+     */
+    public function is_setting_forced($key) {
+        return isset(static::$forcedsettings[$key]);
+    }
+
+    /**
+     * The value of the forced setting.
+     *
+     * @param   string          $key        The name of the key to check.
+     * @return  mixed
+     */
+    public function get_forced_setting_value($key) {
+        if ($this->is_setting_forced($key)) {
+            return static::$forcedsettings[$key];
+        }
+
+        return null;
+    }
+
+    /**
+     * Fetch the targetvalue from the form for this target type.
+     *
+     * @param   stdClass        $data       The data submitted in the form
+     * @return  string
+     */
+    abstract public function get_value_from_form($data);
+}
diff --git a/admin/tool/usertours/classes/local/target/block.php b/admin/tool/usertours/classes/local/target/block.php
new file mode 100644 (file)
index 0000000..878b045
--- /dev/null
@@ -0,0 +1,115 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Block target.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\target;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\step;
+
+/**
+ * Block target.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class block extends base {
+
+    /**
+     * Convert the target value to a valid CSS selector for use in the
+     * output configuration.
+     *
+     * @return string
+     */
+    public function convert_to_css() {
+        // The block has the following CSS class selector style:
+        // .block-region .block_[name] .
+        return sprintf('.block-region .block_%s', $this->step->get_targetvalue());
+    }
+
+    /**
+     * Convert the step target to a friendly name for use in the UI.
+     *
+     * @return string
+     */
+    public function get_displayname() {
+        return get_string('block_named', 'tool_usertours', $this->get_block_name());
+    }
+
+    /**
+     * Get the translated name of the block.
+     *
+     * @return string
+     */
+    protected function get_block_name() {
+        return get_string('pluginname', self::get_frankenstyle($this->step->get_targetvalue()));
+    }
+
+    /**
+     * Get the frankenstyle name of the block.
+     *
+     * @param   string  $block  The block name.
+     * @return                  The frankenstyle block name.
+     */
+    protected static function get_frankenstyle($block) {
+        return sprintf('block_%s', $block);
+    }
+
+    /**
+     * Add the target type configuration to the form.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     * @return  $this
+     */
+    public static function add_config_to_form(\MoodleQuickForm $mform) {
+        global $PAGE;
+
+        $blocks = [];
+        foreach ($PAGE->blocks->get_installed_blocks() as $block) {
+            $blocks[$block->name] = get_string('pluginname', 'block_' . $block->name);
+        }
+
+        $mform->addElement('select', 'targetvalue_block', get_string('block', 'tool_usertours'), $blocks);
+    }
+
+    /**
+     * Add the disabledIf values.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     */
+    public static function add_disabled_constraints_to_form(\MoodleQuickForm $mform) {
+        $mform->disabledIf('targetvalue_block', 'targettype', 'noteq',
+                \tool_usertours\target::get_target_constant_for_class(get_class()));
+    }
+
+    /**
+     * Fetch the targetvalue from the form for this target type.
+     *
+     * @param   stdClass        $data       The data submitted in the form
+     * @return  string
+     */
+    public function get_value_from_form($data) {
+        return $data->targetvalue_block;
+    }
+}
diff --git a/admin/tool/usertours/classes/local/target/selector.php b/admin/tool/usertours/classes/local/target/selector.php
new file mode 100644 (file)
index 0000000..410305c
--- /dev/null
@@ -0,0 +1,107 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Selector target.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\target;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\step;
+
+/**
+ * Selector target.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class selector extends base {
+
+    /**
+     * Convert the target value to a valid CSS selector for use in the
+     * output configuration.
+     *
+     * @return string
+     */
+    public function convert_to_css() {
+        return $this->step->get_targetvalue();
+    }
+
+    /**
+     * Convert the step target to a friendly name for use in the UI.
+     *
+     * @return string
+     */
+    public function get_displayname() {
+        return get_string('selectordisplayname', 'tool_usertours', $this->step->get_targetvalue());
+    }
+
+    /**
+     * Get the default title.
+     *
+     * @return string
+     */
+    public function get_default_title() {
+        return get_string('selector_defaulttitle', 'tool_usertours');
+    }
+
+    /**
+     * Get the default content.
+     *
+     * @return string
+     */
+    public function get_default_content() {
+        return get_string('selector_defaultcontent', 'tool_usertours');
+    }
+
+    /**
+     * Add the target type configuration to the form.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     * @return  $this
+     */
+    public static function add_config_to_form(\MoodleQuickForm $mform) {
+        $mform->addElement('text', 'targetvalue_selector', get_string('cssselector', 'tool_usertours'));
+        $mform->setType('targetvalue_selector', PARAM_RAW);
+        $mform->addHelpButton('targetvalue_selector', 'target_selector_targetvalue', 'tool_usertours');
+    }
+
+    /**
+     * Add the disabledIf values.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     */
+    public static function add_disabled_constraints_to_form(\MoodleQuickForm $mform) {
+        $mform->disabledIf('targetvalue_selector', 'targettype', 'noteq',
+                \tool_usertours\target::get_target_constant_for_class(get_class()));
+    }
+
+    /**
+     * Fetch the targetvalue from the form for this target type.
+     *
+     * @param   stdClass        $data       The data submitted in the form
+     * @return  string
+     */
+    public function get_value_from_form($data) {
+        return $data->targetvalue_selector;
+    }
+}
diff --git a/admin/tool/usertours/classes/local/target/unattached.php b/admin/tool/usertours/classes/local/target/unattached.php
new file mode 100644 (file)
index 0000000..2af061c
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A step designed to be orphaned.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\local\target;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\step;
+
+/**
+ * A step designed to be orphaned.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class unattached extends base {
+    /**
+     * @var     array       $forcedsettings The settings forced by this type.
+     */
+    protected static $forcedsettings = [
+            'placement'     => 'top',
+            'orphan'        => true,
+            'reflex'        => false,
+        ];
+
+    /**
+     * Convert the target value to a valid CSS selector for use in the
+     * output configuration.
+     *
+     * @return string
+     */
+    public function convert_to_css() {
+        return '';
+    }
+
+    /**
+     * Convert the step target to a friendly name for use in the UI.
+     *
+     * @return string
+     */
+    public function get_displayname() {
+        return get_string('target_unattached', 'tool_usertours');
+    }
+
+    /**
+     * Add the target type configuration to the form.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     * @return  $this
+     */
+    public static function add_config_to_form(\MoodleQuickForm $mform) {
+        // There is no relevant value here.
+        $mform->addElement('hidden', 'targetvalue_unattached', '');
+        $mform->setType('targetvalue_unattached', PARAM_TEXT);
+    }
+
+    /**
+     * Add the disabledIf values.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     */
+    public static function add_disabled_constraints_to_form(\MoodleQuickForm $mform) {
+        $myvalue = \tool_usertours\target::get_target_constant_for_class(get_class());
+
+        foreach (array_keys(self::$forcedsettings) as $settingname) {
+            $mform->disabledIf($settingname, 'targettype', 'eq', $myvalue);
+        }
+    }
+
+    /**
+     * Fetch the targetvalue from the form for this target type.
+     *
+     * @param   stdClass        $data       The data submitted in the form
+     * @return  string
+     */
+    public function get_value_from_form($data) {
+        return '';
+    }
+}
diff --git a/admin/tool/usertours/classes/manager.php b/admin/tool/usertours/classes/manager.php
new file mode 100644 (file)
index 0000000..f0c40b2
--- /dev/null
@@ -0,0 +1,769 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Tour manager.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\local\forms;
+use tool_usertours\local\table;
+
+/**
+ * Tour manager.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class manager {
+
+    /**
+     * @var ACTION_LISTTOURS      The action to get the list of tours.
+     */
+    const ACTION_LISTTOURS = 'listtours';
+
+    /**
+     * @var ACTION_NEWTOUR        The action to create a new tour.
+     */
+    const ACTION_NEWTOUR = 'newtour';
+
+    /**
+     * @var ACTION_EDITTOUR       The action to edit the tour.
+     */
+    const ACTION_EDITTOUR = 'edittour';
+
+    /**
+     * @var ACTION_MOVETOUR The action to move a tour up or down.
+     */
+    const ACTION_MOVETOUR = 'movetour';
+
+    /**
+     * @var ACTION_EXPORTTOUR     The action to export the tour.
+     */
+    const ACTION_EXPORTTOUR = 'exporttour';
+
+    /**
+     * @var ACTION_IMPORTTOUR     The action to import the tour.
+     */
+    const ACTION_IMPORTTOUR = 'importtour';
+
+    /**
+     * @var ACTION_DELETETOUR     The action to delete the tour.
+     */
+    const ACTION_DELETETOUR = 'deletetour';
+
+    /**
+     * @var ACTION_VIEWTOUR       The action to view the tour.
+     */
+    const ACTION_VIEWTOUR = 'viewtour';
+
+    /**
+     * @var ACTION_NEWSTEP The action to create a new step.
+     */
+    const ACTION_NEWSTEP = 'newstep';
+
+    /**
+     * @var ACTION_EDITSTEP The action to edit step configuration.
+     */
+    const ACTION_EDITSTEP = 'editstep';
+
+    /**
+     * @var ACTION_MOVESTEP The action to move a step up or down.
+     */
+    const ACTION_MOVESTEP = 'movestep';
+
+    /**
+     * @var ACTION_DELETESTEP The action to delete a step.
+     */
+    const ACTION_DELETESTEP = 'deletestep';
+
+    /**
+     * @var ACTION_VIEWSTEP The action to view a step.
+     */
+    const ACTION_VIEWSTEP = 'viewstep';
+
+    /**
+     * @var ACTION_HIDETOUR The action to hide a tour.
+     */
+    const ACTION_HIDETOUR = 'hidetour';
+
+    /**
+     * @var ACTION_SHOWTOUR The action to show a tour.
+     */
+    const ACTION_SHOWTOUR = 'showtour';
+
+    /**
+     * @var ACTION_RESETFORALL
+     */
+    const ACTION_RESETFORALL = 'resetforall';
+
+    /**
+     * This is the entry point for this controller class.
+     *
+     * @param   string  $action     The action to perform.
+     */
+    public function execute($action) {
+        admin_externalpage_setup('tool_usertours/tours');
+        // Add the main content.
+        switch($action) {
+            case self::ACTION_NEWTOUR:
+            case self::ACTION_EDITTOUR:
+                $this->edit_tour(optional_param('id', null, PARAM_INT));
+                break;
+
+            case self::ACTION_MOVETOUR:
+                $this->move_tour(required_param('id', PARAM_INT));
+                break;
+
+            case self::ACTION_EXPORTTOUR:
+                $this->export_tour(required_param('id', PARAM_INT));
+                break;
+
+            case self::ACTION_IMPORTTOUR:
+                $this->import_tour();
+                break;
+
+            case self::ACTION_VIEWTOUR:
+                $this->view_tour(required_param('id', PARAM_INT));
+                break;
+
+            case self::ACTION_HIDETOUR:
+                $this->hide_tour(required_param('id', PARAM_INT));
+                break;
+
+            case self::ACTION_SHOWTOUR:
+                $this->show_tour(required_param('id', PARAM_INT));
+                break;
+
+            case self::ACTION_DELETETOUR:
+                $this->delete_tour(required_param('id', PARAM_INT));
+                break;
+
+            case self::ACTION_RESETFORALL:
+                $this->reset_tour_for_all(required_param('id', PARAM_INT));
+                break;
+
+            case self::ACTION_NEWSTEP:
+            case self::ACTION_EDITSTEP:
+                $this->edit_step(optional_param('id', null, PARAM_INT));
+                break;
+
+            case self::ACTION_MOVESTEP:
+                $this->move_step(required_param('id', PARAM_INT));
+                break;
+
+            case self::ACTION_DELETESTEP:
+                $this->delete_step(required_param('id', PARAM_INT));
+                break;
+
+            case self::ACTION_LISTTOURS:
+            default:
+                $this->print_tour_list();
+                break;
+        }
+    }
+
+    /**
+     * Print out the page header.
+     *
+     * @param   string  $title     The title to display.
+     */
+    protected function header($title = null) {
+        global $OUTPUT;
+
+        // Print the page heading.
+        echo $OUTPUT->header();
+
+        if ($title === null) {
+            $title = get_string('tours', 'tool_usertours');
+        }
+
+        echo $OUTPUT->heading($title);
+    }
+
+    /**
+     * Print out the page footer.
+     *
+     * @return void
+     */
+    protected function footer() {
+        global $OUTPUT;
+
+        echo $OUTPUT->footer();
+    }
+
+    /**
+     * Print the the list of tours.
+     */
+    protected function print_tour_list() {
+        global $PAGE, $OUTPUT;
+
+        $this->header();
+        echo \html_writer::span(get_string('tourlist_explanation', 'tool_usertours'));
+        $table = new table\tour_list();
+        $tours = helper::get_tours();
+        foreach ($tours as $tour) {
+            $table->add_data_keyed($table->format_row($tour));
+        }
+
+        $table->finish_output();
+        $actions = [
+            (object) [
+                'link'  => helper::get_edit_tour_link(),
+                'linkproperties' => [],
+                'img'   => 'b/tour-new',
+                'title' => get_string('newtour', 'tool_usertours'),
+            ],
+            (object) [
+                'link'  => helper::get_import_tour_link(),
+                'linkproperties' => [],
+                'img'   => 'b/tour-import',
+                'title' => get_string('importtour', 'tool_usertours'),
+            ],
+            (object) [
+                'link'  => new \moodle_url('https://moodle.net/mod/data/view.php', [
+                        'id' => 17,
+                    ]),
+                'linkproperties' => [
+                        'target' => '_blank',
+                    ],
+                'img'   => 'b/tour-shared',
+                'title' => get_string('sharedtourslink', 'tool_usertours'),
+            ],
+        ];
+
+        echo \html_writer::start_tag('div', [
+                'class' => 'tour-actions',
+            ]);
+
+        echo \html_writer::start_tag('ul');
+        foreach ($actions as $config) {
+            $action = \html_writer::start_tag('li');
+            $linkproperties = $config->linkproperties;
+            $linkproperties['href'] = $config->link;
+            $action .= \html_writer::start_tag('a', $linkproperties);
+            $action .= \html_writer::img(
+                $OUTPUT->pix_url($config->img, 'tool_usertours'),
+                $config->title);
+            $action .= \html_writer::div($config->title);
+            $action .= \html_writer::end_tag('a');
+            $action .= \html_writer::end_tag('li');
+            echo $action;
+        }
+        echo \html_writer::end_tag('ul');
+        echo \html_writer::end_tag('div');
+
+        // JS for Tour management.
+        $PAGE->requires->js_call_amd('tool_usertours/managetours', 'setup');
+        $this->footer();
+    }
+
+    /**
+     * Return the edit tour link.
+     *
+     * @param   int         $id     The ID of the tour
+     * @return string
+     */
+    protected function get_edit_tour_link($id = null) {
+        $addlink = helper::get_edit_tour_link($id);
+        return \html_writer::link($addlink, get_string('newtour', 'tool_usertours'));
+    }
+
+    /**
+     * Print the edit tour link.
+     *
+     * @param   int         $id     The ID of the tour
+     */
+    protected function print_edit_tour_link($id = null) {
+        echo $this->get_edit_tour_link($id);
+    }
+
+    /**
+     * Get the import tour link.
+     *
+     * @return string
+     */
+    protected function get_import_tour_link() {
+        $importlink = helper::get_import_tour_link();
+        return \html_writer::link($importlink, get_string('importtour', 'tool_usertours'));
+    }
+
+    /**
+     * Print the edit tour page.
+     *
+     * @param   int         $id     The ID of the tour
+     */
+    protected function edit_tour($id = null) {
+        global $PAGE;
+        if ($id) {
+            $tour = tour::instance($id);
+            $PAGE->navbar->add($tour->get_name(), $tour->get_edit_link());
+
+        } else {
+            $tour = new tour();
+            $PAGE->navbar->add(get_string('newtour', 'tool_usertours'), $tour->get_edit_link());
+        }
+
+        $form = new forms\edittour($tour);
+
+        if ($form->is_cancelled()) {
+            redirect(helper::get_list_tour_link());
+        } else if ($data = $form->get_data()) {
+            // Creating a new tour.
+            $tour->set_name($data->name);
+            $tour->set_description($data->description);
+            $tour->set_pathmatch($data->pathmatch);
+            $tour->set_enabled(!empty($data->enabled));
+
+            foreach (configuration::get_defaultable_keys() as $key) {
+                $tour->set_config($key, $data->$key);
+            }
+
+            // Save filter values.
+            foreach (helper::get_all_filters() as $filterclass) {
+                $filterclass::save_filter_values_from_form($tour, $data);
+            }
+
+            $tour->persist();
+
+            redirect(helper::get_list_tour_link());
+        } else {
+            if (empty($tour)) {
+                $this->header('newtour');
+            } else {
+                $this->header($tour->get_name());
+                $data = $tour->prepare_data_for_form();
+
+                // Prepare filter values for the form.
+                foreach (helper::get_all_filters() as $filterclass) {
+                    $filterclass::prepare_filter_values_for_form($tour, $data);
+                }
+                $form->set_data($data);
+            }
+
+            $form->display();
+            $this->footer();
+        }
+    }
+
+    /**
+     * Print the export tour page.
+     *
+     * @param   int         $id     The ID of the tour
+     */
+    protected function export_tour($id) {
+        $tour = tour::instance($id);
+
+        // Grab the full data record.
+        $export = $tour->to_record();
+
+        // Remove the id.
+        unset($export->id);
+
+        // Set the version.
+        $export->version = get_config('tool_usertours', 'version');
+
+        // Step export.
+        $export->steps = [];
+        foreach ($tour->get_steps() as $step) {
+            $record = $step->to_record();
+            unset($record->id);
+            unset($record->tourid);
+
+            $export->steps[] = $record;
+        }
+
+        $exportstring = json_encode($export);
+
+        $filename = 'tour_export_' . $tour->get_id() . '_' . time() . '.json';
+
+        // Force download.
+        header('Last-Modified: ' . gmdate('D, d M Y H:i:s', time()) . ' GMT');
+        header('Cache-Control: private, must-revalidate, pre-check=0, post-check=0, max-age=0');
+        header('Expires: ' . gmdate('D, d M Y H:i:s', 0) . 'GMT');
+        header('Pragma: no-cache');
+        header('Accept-Ranges: none');
+        header('Content-disposition: attachment; filename=' . $filename);
+        header('Content-length: ' . strlen($exportstring));
+        header('Content-type: text/calendar; charset=utf-8');
+
+        echo $exportstring;
+        die;
+    }
+
+    /**
+     * Handle tour import.
+     */
+    protected function import_tour() {
+        global $PAGE;
+        $PAGE->navbar->add(get_string('importtour', 'tool_usertours'), helper::get_import_tour_link());
+
+        $form = new forms\importtour();
+
+        if ($form->is_cancelled()) {
+            redirect(helper::get_list_tour_link());
+        } else if ($form->get_data()) {
+            // Importing a tour.
+            $tourconfigraw = $form->get_file_content('tourconfig');
+            $tour = self::import_tour_from_json($tourconfigraw);
+
+            redirect($tour->get_view_link());
+        } else {
+            $this->header();
+            $form->display();
+            $this->footer();
+        }
+    }
+
+    /**
+     * Print the view tour page.
+     *
+     * @param   int         $tourid     The ID of the tour to display.
+     */
+    protected function view_tour($tourid) {
+        global $PAGE;
+        $tour = helper::get_tour($tourid);
+
+        $PAGE->navbar->add($tour->get_name(), $tour->get_view_link());
+
+        $this->header($tour->get_name());
+        echo \html_writer::span(get_string('viewtour_info', 'tool_usertours', [
+                'tourname'  => $tour->get_name(),
+                'path'      => $tour->get_pathmatch(),
+            ]));
+        echo \html_writer::div(get_string('viewtour_edit', 'tool_usertours', [
+                'editlink'  => $tour->get_edit_link()->out(),
+                'resetlink' => $tour->get_reset_link()->out(),
+            ]));
+
+        $table = new table\step_list($tourid);
+        foreach ($tour->get_steps() as $step) {
+            $table->add_data_keyed($table->format_row($step));
+        }
+
+        $table->finish_output();
+        $this->print_edit_step_link($tourid);
+
+        // JS for Step management.
+        $PAGE->requires->js_call_amd('tool_usertours/managesteps', 'setup');
+
+        $this->footer();
+    }
+
+    /**
+     * Show the tour.
+     *
+     * @param   int         $tourid     The ID of the tour to display.
+     */
+    protected function show_tour($tourid) {
+        $this->show_hide_tour($tourid, 1);
+    }
+
+    /**
+     * Hide the tour.
+     *
+     * @param   int         $tourid     The ID of the tour to display.
+     */
+    protected function hide_tour($tourid) {
+        $this->show_hide_tour($tourid, 0);
+    }
+
+    /**
+     * Show or Hide the tour.
+     *
+     * @param   int         $tourid     The ID of the tour to display.
+     * @param   int         $visibility The intended visibility.
+     */
+    protected function show_hide_tour($tourid, $visibility) {
+        global $DB;
+
+        require_sesskey();
+
+        $tour = $DB->get_record('tool_usertours_tours', array('id' => $tourid));
+        $tour->enabled = $visibility;
+        $DB->update_record('tool_usertours_tours', $tour);
+
+        redirect(helper::get_list_tour_link());
+    }
+
+    /**
+     * Delete the tour.
+     *
+     * @param   int         $tourid     The ID of the tour to remove.
+     */
+    protected function delete_tour($tourid) {
+        require_sesskey();
+
+        $tour = tour::instance($tourid);
+        $tour->remove();
+
+        redirect(helper::get_list_tour_link());
+    }
+
+    /**
+     * Reset the tour state for all users.
+     *
+     * @param   int         $tourid     The ID of the tour to remove.
+     */
+    protected function reset_tour_for_all($tourid) {
+        require_sesskey();
+
+        $tour = tour::instance($tourid);
+        $tour->mark_major_change();
+
+        redirect(helper::get_view_tour_link($tourid), get_string('tour_resetforall', 'tool_usertours'));
+    }
+
+    /**
+     * Get the first tour matching the current page URL.
+     *
+     * @param   bool        $reset      Forcibly update the current tour
+     * @return  tour
+     */
+    public static function get_current_tour($reset = false) {
+        global $PAGE;
+
+        static $tour = false;
+
+        if ($tour === false || $reset) {
+            $tour = self::get_matching_tours($PAGE->url);
+        }
+
+        return $tour;
+    }
+
+    /**
+     * Get the first tour matching the specified URL.
+     *
+     * @param   moodle_url  $pageurl        The URL to match.
+     * @return  tour
+     */
+    public static function get_matching_tours(\moodle_url $pageurl) {
+        global $DB, $PAGE;
+
+        // Do not show tours whilst upgrades are pending.
+        if (moodle_needs_upgrading()) {
+            return null;
+        }
+
+        $sql = <<<EOF
+            SELECT * FROM {tool_usertours_tours}
+             WHERE enabled = 1
+               AND ? LIKE pathmatch
+          ORDER BY sortorder ASC
+EOF;
+
+        $tours = $DB->get_records_sql($sql, array(
+            $pageurl->out_as_local_url(),
+        ));
+
+        foreach ($tours as $record) {
+            $tour = tour::load_from_record($record);
+            if ($tour->is_enabled() && $tour->matches_all_filters($PAGE->context) & $tour->get_steps() > 0) {
+                return $tour;
+            }
+        }
+
+        return null;
+    }
+
+    /**
+     * Import the provided tour JSON.
+     *
+     * @param   string      $json           The tour configuration.
+     * @return  tour
+     */
+    public static function import_tour_from_json($json) {
+        $tourconfig = json_decode($json);
+
+        // We do not use this yet - we may do in the future.
+        unset($tourconfig->version);
+
+        $steps = $tourconfig->steps;
+        unset($tourconfig->steps);
+
+        $tourconfig->id = null;
+        $tourconfig->sortorder = null;
+        $tour = tour::load_from_record($tourconfig, true);
+        $tour->persist(true);
+
+        // Ensure that steps are orderered by their sortorder.
+        \core_collator::asort_objects_by_property($steps, 'sortorder', \core_collator::SORT_NUMERIC);
+
+        foreach ($steps as $stepconfig) {
+            $stepconfig->id = null;
+            $stepconfig->tourid = $tour->get_id();
+            $step = step::load_from_record($stepconfig, true);
+            $step->persist(true);
+        }
+
+        return $tour;
+    }
+
+    /**
+     * Helper to fetch the renderer.
+     *
+     * @return  renderer
+     */
+    protected function get_renderer() {
+        global $PAGE;
+        return $PAGE->get_renderer('tool_usertours');
+    }
+
+    /**
+     * Print the edit step link.
+     *
+     * @param   int     $tourid     The ID of the tour.
+     * @param   int     $stepid     The ID of the step.
+     * @return  string
+     */
+    protected function print_edit_step_link($tourid, $stepid = null) {
+        $addlink = helper::get_edit_step_link($tourid, $stepid);
+        $attributes = [];
+        if (empty($stepid)) {
+            $attributes['class'] = 'createstep';
+        }
+        echo \html_writer::link($addlink, get_string('newstep', 'tool_usertours'), $attributes);
+    }
+
+    /**
+     * Display the edit step form for the specified step.
+     *
+     * @param   int     $id     The step to edit.
+     */
+    protected function edit_step($id) {
+        global $PAGE;
+
+        if (isset($id)) {
+            $step = step::instance($id);
+        } else {
+            $step = new step();
+            $step->set_tourid(required_param('tourid', PARAM_INT));
+        }
+
+        $tour = $step->get_tour();
+        $PAGE->navbar->add($tour->get_name(), $tour->get_view_link());
+        if (isset($id)) {
+            $PAGE->navbar->add($step->get_title(), $step->get_edit_link());
+        } else {
+            $PAGE->navbar->add(get_string('newstep', 'tool_usertours'), $step->get_edit_link());
+        }
+
+        $form = new forms\editstep($step->get_edit_link(), $step);
+        if ($form->is_cancelled()) {
+            redirect($step->get_tour()->get_view_link());
+        } else if ($data = $form->get_data()) {
+            $step->handle_form_submission($form, $data);
+            $step->get_tour()->reset_step_sortorder();
+            redirect($step->get_tour()->get_view_link());
+        } else {
+            if (empty($id)) {
+                $this->header(get_string('newstep', 'tool_usertours'));
+            } else {
+                $this->header(get_string('editstep', 'tool_usertours', $step->get_title()));
+            }
+            $form->set_data($step->prepare_data_for_form());
+
+            $form->display();
+            $this->footer();
+        }
+    }
+
+    /**
+     * Move a tour up or down.
+     *
+     * @param   int     $id     The tour to move.
+     */
+    protected function move_tour($id) {
+        require_sesskey();
+
+        $direction = required_param('direction', PARAM_INT);
+
+        $tour = tour::instance($id);
+        $currentsortorder   = $tour->get_sortorder();
+        $targetsortorder    = $currentsortorder + $direction;
+
+        $swapwith = helper::get_tour_from_sortorder($targetsortorder);
+
+        // Set the sort order to something out of the way.
+        $tour->set_sortorder(-1);
+        $tour->persist();
+
+        // Swap the two sort orders.
+        $swapwith->set_sortorder($currentsortorder);
+        $swapwith->persist();
+
+        $tour->set_sortorder($targetsortorder);
+        $tour->persist();
+
+        redirect(helper::get_list_tour_link());
+    }
+
+    /**
+     * Move a step up or down.
+     *
+     * @param   int     $id     The step to move.
+     */
+    protected function move_step($id) {
+        require_sesskey();
+
+        $direction = required_param('direction', PARAM_INT);
+
+        $step = step::instance($id);
+        $currentsortorder   = $step->get_sortorder();
+        $targetsortorder    = $currentsortorder + $direction;
+
+        $tour = $step->get_tour();
+        $swapwith = helper::get_step_from_sortorder($tour->get_id(), $targetsortorder);
+
+        // Set the sort order to something out of the way.
+        $step->set_sortorder(-1);
+        $step->persist();
+
+        // Swap the two sort orders.
+        $swapwith->set_sortorder($currentsortorder);
+        $swapwith->persist();
+
+        $step->set_sortorder($targetsortorder);
+        $step->persist();
+
+        // Reset the sort order.
+        $tour->reset_step_sortorder();
+        redirect($tour->get_view_link());
+    }
+
+    /**
+     * Delete the step.
+     *
+     * @param   int         $stepid     The ID of the step to remove.
+     */
+    protected function delete_step($stepid) {
+        require_sesskey();
+
+        $step = step::instance($stepid);
+        $tour = $step->get_tour();
+
+        $step->remove();
+        redirect($tour->get_view_link());
+    }
+}
diff --git a/admin/tool/usertours/classes/output/renderer.php b/admin/tool/usertours/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..27c1ae3
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Renderer.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Renderer.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends \plugin_renderer_base {
+}
diff --git a/admin/tool/usertours/classes/output/step.php b/admin/tool/usertours/classes/output/step.php
new file mode 100644 (file)
index 0000000..3da0d09
--- /dev/null
@@ -0,0 +1,121 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Tour Step Renderable.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\step as stepsource;
+
+/**
+ * Tour Step Renderable.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class step implements \renderable {
+
+    /**
+     * @var The step instance.
+     */
+    protected $step;
+
+    /**
+     * The step output.
+     *
+     * @param   stepsource      $step       The step being output.
+     */
+    public function __construct(stepsource $step) {
+        $this->step = $step;
+    }
+
+    /**
+     * Export the step configuration.
+     *
+     * @param   renderer_base   $output     The renderer.
+     * @return  object
+     */
+    public function export_for_template(\renderer_base $output) {
+        global $PAGE;
+        $step = $this->step;
+
+        $result = (object) [
+            'stepid'    => $step->get_id(),
+            'title'     => external_format_text(
+                    static::get_string_from_input($step->get_title()),
+                    FORMAT_HTML,
+                    $PAGE->context->id,
+                    'tool_usertours',
+                    null,
+                    null,
+                    [
+                        'filter' => true,
+                    ]
+                )[0],
+            'content'   => external_format_text(
+                    static::get_string_from_input($step->get_content()),
+                    FORMAT_HTML,
+                    $PAGE->context->id,
+                    'tool_usertours',
+                    null,
+                    null,
+                    [
+                        'filter' => true,
+                    ]
+                )[0],
+            'element'   => $step->get_target()->convert_to_css(),
+        ];
+
+        $result->content = str_replace("\n", "<br>\n", $result->content);
+
+        foreach ($step->get_config_keys() as $key) {
+            $result->$key = $step->get_config($key);
+        }
+
+        return $result;
+    }
+
+    /**
+     * Attempt to fetch any matching langstring if the string is in the
+     * format identifier,component.
+     *
+     * @param   string  $string
+     * @return  string
+     */
+    protected static function get_string_from_input($string) {
+        $string = trim($string);
+
+        if (preg_match('|^([a-zA-Z][a-zA-Z0-9\.:/_-]*),([a-zA-Z][a-zA-Z0-9\.:/_-]*)$|', $string, $matches)) {
+            if ($matches[2] === 'moodle') {
+                $matches[2] = 'core';
+            }
+
+            if (get_string_manager()->string_exists($matches[1], $matches[2])) {
+                $string = get_string($matches[1], $matches[2]);
+            }
+        }
+
+        return $string;
+    }
+}
diff --git a/admin/tool/usertours/classes/output/tour.php b/admin/tool/usertours/classes/output/tour.php
new file mode 100644 (file)
index 0000000..dda00f4
--- /dev/null
@@ -0,0 +1,71 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Tour renderable.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use tool_usertours\tour as toursource;
+
+/**
+ * Tour renderable.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tour implements \renderable {
+
+    /**
+     * @var The tour instance.
+     */
+    protected $tour;
+
+    /**
+     * The tour output.
+     *
+     * @param   toursource      $tour       The tour being output.
+     */
+    public function __construct (toursource $tour) {
+        $this->tour = $tour;
+    }
+
+    /**
+     * Prepare the data for export.
+     *
+     * @param   \renderer_base      $output     The output renderable.
+     * @return  object
+     */
+    public function export_for_template(\renderer_base $output) {
+        $result = (object) [
+            'name'  => $this->tour->get_tour_key(),
+            'steps' => [],
+        ];
+
+        foreach ($this->tour->get_steps() as $step) {
+            $result->steps[] = (new step($step))->export_for_template($output);
+        }
+
+        return $result;
+    }
+}
diff --git a/admin/tool/usertours/classes/step.php b/admin/tool/usertours/classes/step.php
new file mode 100644 (file)
index 0000000..7b1ca27
--- /dev/null
@@ -0,0 +1,629 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Step class.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Step class.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class step {
+
+    /**
+     * @var     int     $id         The id of the step.
+     */
+    protected $id;
+
+    /**
+     * @var     int     $tourid     The id of the tour that this step belongs to.
+     */
+    protected $tourid;
+
+    /**
+     * @var     tour    $tour       The tour class that this step belongs to.
+     */
+    protected $tour;
+
+    /**
+     * @var     string  $title      The title of the step.
+     */
+    protected $title;
+
+    /**
+     * @var     string  $content    The content of this step.
+     */
+    protected $content;
+
+    /**
+     * @var     int     $targettype The type of target.
+     */
+    protected $targettype;
+
+    /**
+     * @var     string  $targetvalue    The value for this type of target.
+     */
+    protected $targetvalue;
+
+    /**
+     * @var     int     $sortorder  The sort order.
+     */
+    protected $sortorder;
+
+    /**
+     * @var     object  $config     The configuration as an object.
+     */
+    protected $config;
+
+    /**
+     * @var     bool    $dirty      Whether the step has been changed since it was loaded
+     */
+    protected $dirty = false;
+
+    /**
+     * Fetch the step instance.
+     *
+     * @param   int             $id         The id of the step to be retrieved.
+     * @return  step
+     */
+    public static function instance($id) {
+        $step = new step();
+        return $step->fetch($id);
+    }
+
+    /**
+     * Load the step instance.
+     *
+     * @param   stdClass        $record     The step record to be loaded.
+     * @param   boolean         $clean      Clean the values.
+     * @return  step
+     */
+    public static function load_from_record($record, $clean = false) {
+        $step = new self();
+        return $step->reload_from_record($record, $clean);
+    }
+
+    /**
+     * Fetch the step instance.
+     *
+     * @param   int             $id         The id of the step to be retrieved.
+     * @return  step
+     */
+    protected function fetch($id) {
+        global $DB;
+
+        return $this->reload_from_record(
+            $DB->get_record('tool_usertours_steps', array('id' => $id))
+        );
+    }
+
+    /**
+     * Refresh the current step from the datbase.
+     *
+     * @return  step
+     */
+    protected function reload() {
+        return $this->fetch($this->id);
+    }
+
+    /**
+     * Reload the current step from the supplied record.
+     *
+     * @param   stdClass        $record     The step record to be loaded.
+     * @param   boolean         $clean      Clean the values.
+     * @return  step
+     */
+    protected function reload_from_record($record, $clean = false) {
+        $this->id           = $record->id;
+        $this->tourid       = $record->tourid;
+        if ($clean) {
+            $this->title    = clean_param($record->title, PARAM_TEXT);
+            $this->content  = clean_text($record->content);
+        } else {
+            $this->title    = $record->title;
+            $this->content  = $record->content;
+        }
+        $this->targettype   = $record->targettype;
+        $this->targetvalue  = $record->targetvalue;
+        $this->sortorder    = $record->sortorder;
+        $this->config       = json_decode($record->configdata);
+        $this->dirty        = false;
+
+        return $this;
+    }
+
+    /**
+     * Get the ID of the step.
+     *
+     * @return  int
+     */
+    public function get_id() {
+        return $this->id;
+    }
+
+    /**
+     * Get the Tour ID of the step.
+     *
+     * @return  int
+     */
+    public function get_tourid() {
+        return $this->tourid;
+    }
+
+    /**
+     * Get the Tour instance that this step belongs to.
+     *
+     * @return  tour
+     */
+    public function get_tour() {
+        if ($this->tour === null) {
+            $this->tour = tour::instance($this->tourid);
+        }
+        return $this->tour;
+    }
+
+    /**
+     * Set the id of the tour.
+     *
+     * @param   int             $value      The id of the tour.
+     * @return  self
+     */
+    public function set_tourid($value) {
+        $this->tourid = $value;
+        $this->tour = null;
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * Get the Title of the step.
+     *
+     * @return  string
+     */
+    public function get_title() {
+        return $this->title;
+    }
+
+    /**
+     * Set the title for this step.
+     *
+     * @param   string      $value      The new title to use.
+     * @return  $this
+     */
+    public function set_title($value) {
+        $this->title = clean_param($value, PARAM_TEXT);
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * Get the body content of the step.
+     *
+     * @return  string
+     */
+    public function get_content() {
+        return $this->content;
+    }
+
+    /**
+     * Set the content value for this step.
+     *
+     * @param   string      $value      The new content to use.
+     * @return  $this
+     */
+    public function set_content($value) {
+        $this->content = clean_text($value);
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * Get the content value for this step.
+     *
+     * @return  string
+     */
+    public function get_targettype() {
+        return $this->targettype;
+    }
+
+    /**
+     * Set the type of target for this step.
+     *
+     * @param   string      $value      The new target to use.
+     * @return  $this
+     */
+    public function set_targettype($value) {
+        $this->targettype = $value;
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * Get the target value for this step.
+     *
+     * @return  string
+     */
+    public function get_targetvalue() {
+        return $this->targetvalue;
+    }
+
+    /**
+     * Set the target value for this step.
+     *
+     * @param   string      $value      The new target value to use.
+     * @return  $this
+     */
+    public function set_targetvalue($value) {
+        $this->targetvalue = $value;
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * Get the target instance for this step.
+     *
+     * @return  target
+     */
+    public function get_target() {
+        return target::get_target_instance($this);
+    }
+
+    /**
+     * Get the current sortorder for this step.
+     *
+     * @return  int
+     */
+    public function get_sortorder() {
+        return (int) $this->sortorder;
+    }
+
+    /**
+     * Whether this step is the first step in the tour.
+     *
+     * @return  boolean
+     */
+    public function is_first_step() {
+        return ($this->get_sortorder() === 0);
+    }
+
+    /**
+     * Whether this step is the last step in the tour.
+     *
+     * @return  boolean
+     */
+    public function is_last_step() {
+        $stepcount = $this->get_tour()->count_steps();
+        return ($this->get_sortorder() === $stepcount - 1);
+    }
+
+    /**
+     * Set the sortorder for this step.
+     *
+     * @param   int         $value      The new sortorder to use.
+     * @return  $this
+     */
+    public function set_sortorder($value) {
+        $this->sortorder = $value;
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * Get the link to move this step up in the sortorder.
+     *
+     * @return  moodle_url
+     */
+    public function get_moveup_link() {
+        return helper::get_move_step_link($this->get_id(), helper::MOVE_UP);
+    }
+
+    /**
+     * Get the link to move this step down in the sortorder.
+     *
+     * @return  moodle_url
+     */
+    public function get_movedown_link() {
+        return helper::get_move_step_link($this->get_id(), helper::MOVE_DOWN);
+    }
+
+    /**
+     * Get the value of the specified configuration item.
+     *
+     * If notvalue was found, and no default was specified, the default for the tour will be used.
+     *
+     * @param   string      $key        The configuration key to set.
+     * @param   mixed       $default    The default value to use if a value was not found.
+     * @return  mixed
+     */
+    public function get_config($key = null, $default = null) {
+        if ($this->config === null) {
+            $this->config = (object) array();
+        }
+
+        if ($key === null) {
+            return $this->config;
+        }
+
+        if ($this->get_targettype()) {
+            $target = $this->get_target();
+            if ($target->is_setting_forced($key)) {
+                return $target->get_forced_setting_value($key);
+            }
+        }
+
+        if (property_exists($this->config, $key)) {
+            return $this->config->$key;
+        }
+
+        if ($default !== null) {
+            return $default;
+        }
+
+        return $this->get_tour()->get_config($key);
+    }
+
+    /**
+     * Set the configuration item as specified.
+     *
+     * @param   string      $key        The configuration key to set.
+     * @param   mixed       $value      The new value for the configuration item.
+     * @return  $this
+     */
+    public function set_config($key, $value) {
+        if ($this->config === null) {
+            $this->config = (object) array();
+        }
+
+        if ($value === null) {
+            unset($this->config->$key);
+        } else {
+            $this->config->$key = $value;
+        }
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * Get the edit link for this step.
+     *
+     * @return  moodle_url
+     */
+    public function get_edit_link() {
+        return helper::get_edit_step_link($this->tourid, $this->id);
+    }
+
+    /**
+     * Get the delete link for this step.
+     *
+     * @return  moodle_url
+     */
+    public function get_delete_link() {
+        return helper::get_delete_step_link($this->id);
+    }
+
+    /**
+     * Prepare this step for saving to the database.
+     *
+     * @return  object
+     */
+    public function to_record() {
+        return (object) array(
+            'id'            => $this->id,
+            'tourid'        => $this->tourid,
+            'title'         => $this->title,
+            'content'       => $this->content,
+            'targettype'    => $this->targettype,
+            'targetvalue'   => $this->targetvalue,
+            'sortorder'     => $this->sortorder,
+            'configdata'    => json_encode($this->config),
+        );
+    }
+
+    /**
+     * Calculate the next sort-order value.
+     *
+     * @return  int
+     */
+    protected function calculate_sortorder() {
+        $count = $this->get_tour()->count_steps();
+        $this->sortorder = $count;
+
+        return $this;
+    }
+
+    /**
+     * Save the tour and it's configuration to the database.
+     *
+     * @param   boolean     $force      Whether to force writing to the database.
+     * @return  $this
+     */
+    public function persist($force = false) {
+        global $DB;
+
+        if (!$this->dirty && !$force) {
+            return $this;
+        }
+
+        if ($this->id) {
+            $record = $this->to_record();
+            $DB->update_record('tool_usertours_steps', $record);
+        } else {
+            $this->calculate_sortorder();
+            $record = $this->to_record();
+            unset($record->id);
+            $this->id = $DB->insert_record('tool_usertours_steps', $record);
+        }
+
+        $this->reload();
+
+        return $this;
+    }
+
+    /**
+     * Remove this step.
+     */
+    public function remove() {
+        global $DB;
+
+        if ($this->id === null) {
+            return;
+        }
+
+        $DB->delete_records('tool_usertours_steps', array('id' => $this->id));
+        $this->get_tour()->reset_step_sortorder();
+    }
+
+    /**
+     * Get the list of possible placement options.
+     *
+     * @return  array
+     */
+    public function get_placement_options() {
+        return configuration::get_placement_options(true);
+    }
+
+    /**
+     * The list of possible configuration keys.
+     *
+     * @return  array
+     */
+    public static function get_config_keys() {
+        return [
+            'placement',
+            'orphan',
+            'backdrop',
+            'reflex',
+        ];
+    }
+
+    /**
+     * Add the step configuration to the form.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     * @return  $this
+     */
+    public function add_config_to_form(\MoodleQuickForm $mform) {
+        $tour = $this->get_tour();
+
+        $options = configuration::get_placement_options($tour->get_config('placement'));
+        $mform->addElement('select', 'placement', get_string('placement', 'tool_usertours'), $options);
+        $mform->addHelpButton('placement', 'placement', 'tool_usertours');
+
+        $this->add_config_field_to_form($mform, 'orphan');
+        $this->add_config_field_to_form($mform, 'backdrop');
+        $this->add_config_field_to_form($mform, 'reflex');
+
+        return $this;
+    }
+
+    /**
+     * Add the specified step field configuration to the form.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     * @param   string          $key        The key to add.
+     * @return  $this
+     */
+    public function add_config_field_to_form(\MoodleQuickForm $mform, $key) {
+        $tour = $this->get_tour();
+
+        $default = (bool) $tour->get_config($key);
+
+        $options = [
+            true    => get_string('yes'),
+            false   => get_string('no'),
+        ];
+
+        if (!isset($options[$default])) {
+            $default = configuration::get_default_value($key);
+        }
+
+        $options = array_reverse($options, true);
+        $options[configuration::TOURDEFAULT] = get_string('defaultvalue', 'tool_usertours', $options[$default]);
+        $options = array_reverse($options, true);
+
+        $mform->addElement('select', $key, get_string($key, 'tool_usertours'), $options);
+        $mform->setDefault($key, configuration::TOURDEFAULT);
+        $mform->addHelpButton($key, $key, 'tool_usertours');
+
+        return $this;
+    }
+
+    /**
+     * Prepare the configuration data for the moodle form.
+     *
+     * @return  object
+     */
+    public function prepare_data_for_form() {
+        $data = $this->to_record();
+        foreach (self::get_config_keys() as $key) {
+            $data->$key = $this->get_config($key, configuration::get_step_default_value($key));
+        }
+
+        return $data;
+    }
+
+    /**
+     * Handle submission of the step editing form.
+     *
+     * @param   local\forms\editstep  $mform      The sumitted form.
+     * @param   stdClass        $data       The submitted data.
+     * @return  $this
+     */
+    public function handle_form_submission(local\forms\editstep &$mform, \stdClass $data) {
+        $this->set_title($data->title);
+        $this->set_content($data->content);
+        $this->set_targettype($data->targettype);
+
+        $this->set_targetvalue($this->get_target()->get_value_from_form($data));
+
+        foreach (self::get_config_keys() as $key) {
+            if (!$this->get_target()->is_setting_forced($key)) {
+                if (isset($data->$key)) {
+                    $value = $data->$key;
+                } else {
+                    $value = configuration::TOURDEFAULT;
+                }
+                if ($value === configuration::TOURDEFAULT) {
+                    $this->set_config($key, null);
+                } else {
+                    $this->set_config($key, $value);
+                }
+            }
+        }
+
+        $this->persist();
+
+        return $this;
+    }
+}
diff --git a/admin/tool/usertours/classes/target.php b/admin/tool/usertours/classes/target.php
new file mode 100644 (file)
index 0000000..f65c2ca
--- /dev/null
@@ -0,0 +1,128 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Target class.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Target class.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class target {
+
+    /**
+     * @var TARGET_SELECTOR The target is a CSS selector.
+     */
+    const TARGET_SELECTOR = 0;
+
+    /**
+     * @var TARGET_BLOCK The target is a block.
+     */
+    const TARGET_BLOCK = 1;
+
+    /**
+     * @var TARGET_UNATTACHED The target is unattached to any specific node.
+     */
+    const TARGET_UNATTACHED = 2;
+
+    /**
+     * @var     array   $mapping    The list of target type to target name.
+     */
+    protected static $mapping = [
+        self::TARGET_BLOCK      => 'block',
+        self::TARGET_SELECTOR   => 'selector',
+        self::TARGET_UNATTACHED => 'unattached',
+    ];
+
+    /**
+     * Return the name of the class for this target type.
+     *
+     * @param   int     $type       The type of target.
+     * @return  string              The class name.
+     */
+    public static function get_classname($type) {
+        $targettype = self::$mapping[self::get_target_constant($type)];
+        return "\\tool_usertours\\local\\target\\{$targettype}";
+    }
+
+    /**
+     * Return the instance of the class for this target.
+     *
+     * @param   step    $step       The step.
+     * @return  target              The target instance.
+     */
+    public static function get_target_type(step $step) {
+        if (!isset(self::$mapping[$step->get_targettype()])) {
+            throw new \moodle_exception('Unknown Target type');
+        }
+
+        $targettype = self::$mapping[$step->get_targettype()];
+        return "\\tool_usertours\\local\\target\\{$targettype}";
+    }
+
+    /**
+     * Return the constant used to describe this target.
+     *
+     * @param   string  $type       The type of the target.
+     * @return  int                 The constant for this target.
+     */
+    public static function get_target_constant($type) {
+        return array_search($type, self::$mapping);
+    }
+
+    /**
+     * Return the constant used to describe this class.
+     *
+     * @param   string  $classname  The fully-qualified class name of the target
+     * @return  int                 The constant for this target.
+     */
+    public static function get_target_constant_for_class($classname) {
+        $rc = new \ReflectionClass($classname);
+
+        return self::get_target_constant($rc->getShortName());
+    }
+
+    /**
+     * Return the instance of the class for this target.
+     *
+     * @param   step    $step       The step.
+     * @return  target              The target instance.
+     */
+    public static function get_target_instance(step $step) {
+        $targetclass = self::get_target_type($step);
+        return new $targetclass($step);
+    }
+
+    /**
+     * Return the complete lits of target types.
+     *
+     * @return  array
+     */
+    public static function get_target_types() {
+        return self::$mapping;
+    }
+}
diff --git a/admin/tool/usertours/classes/tour.php b/admin/tool/usertours/classes/tour.php
new file mode 100644 (file)
index 0000000..b628744
--- /dev/null
@@ -0,0 +1,761 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Tour class.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_usertours;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Tour class.
+ *
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tour {
+
+    /**
+     * The tour is currently disabled
+     *
+     * @var DISABLED
+     */
+    const DISABLED = 0;
+
+    /**
+     * The tour is currently disabled
+     *
+     * @var DISABLED
+     */
+    const ENABLED = 1;
+
+    /**
+     * The user preference value to indicate the time of completion of the tour for a user.
+     *
+     * @var TOUR_LAST_COMPLETED_BY_USER
+     */
+    const TOUR_LAST_COMPLETED_BY_USER   = 'tool_usertours_tour_completion_time_';
+
+    /**
+     * The user preference value to indicate the time that a user last requested to see the tour.
+     *
+     * @var TOUR_REQUESTED_BY_USER
+     */
+    const TOUR_REQUESTED_BY_USER        = 'tool_usertours_tour_reset_time_';
+
+    /**
+     * @var $id The tour ID.
+     */
+    protected $id;
+
+    /**
+     * @var $name The tour name.
+     */
+    protected $name;
+
+    /**
+     * @var $description The tour description.
+     */
+    protected $description;
+
+    /**
+     * @var $pathmatch The tour pathmatch.
+     */
+    protected $pathmatch;
+
+    /**
+     * @var $enabled The tour enabled state.
+     */
+    protected $enabled;
+
+    /**
+     * @var $sortorder The sort order.
+     */
+    protected $sortorder;
+
+    /**
+     * @var $dirty Whether the current view of the tour has been modified.
+     */
+    protected $dirty = false;
+
+    /**
+     * @var $config The configuration object for the tour.
+     */
+    protected $config;
+
+    /**
+     * @var $filtervalues The filter configuration object for the tour.
+     */
+    protected $filtervalues;
+
+    /**
+     * @var $steps  The steps in this tour.
+     */
+    protected $steps = [];
+
+    /**
+     * Create an instance of the specified tour.
+     *
+     * @param   int         $id         The ID of the tour to load.
+     * @return  tour
+     */
+    public static function instance($id) {
+        $tour = new self();
+        return $tour->fetch($id);
+    }
+
+    /**
+     * Create an instance of tour from its provided DB record.
+     *
+     * @param   stdClass    $record     The record of the tour to load.
+     * @param   boolean     $clean      Clean the values.
+     * @return  tour
+     */
+    public static function load_from_record($record, $clean = false) {
+        $tour = new self();
+        return $tour->reload_from_record($record, $clean);
+    }
+
+    /**
+     * Fetch the specified tour into the current object.
+     *
+     * @param   int         $id         The ID of the tour to fetch.
+     * @return  tour
+     */
+    protected function fetch($id) {
+        global $DB;
+
+        return $this->reload_from_record(
+            $DB->get_record('tool_usertours_tours', array('id' => $id), '*', MUST_EXIST)
+        );
+    }
+
+    /**
+     * Reload the current tour from database.
+     *
+     * @return  tour
+     */
+    protected function reload() {
+        return $this->fetch($this->id);
+    }
+
+    /**
+     * Reload the tour into the current object.
+     *
+     * @param   stdClass    $record     The record to reload.
+     * @param   boolean     $clean      Clean the values.
+     * @return  tour
+     */
+    protected function reload_from_record($record, $clean = false) {
+        $this->id           = $record->id;
+        if (!property_exists($record, 'description')) {
+            if (property_exists($record, 'comment')) {
+                $record->description = $record->comment;
+                unset($record->comment);
+            }
+        }
+        if ($clean) {
+            $this->name         = clean_param($record->name, PARAM_TEXT);
+            $this->description  = clean_text($record->description);
+        } else {
+            $this->name         = $record->name;
+            $this->description  = $record->description;
+        }
+        $this->pathmatch    = $record->pathmatch;
+        $this->enabled      = $record->enabled;
+        if (isset($record->sortorder)) {
+            $this->sortorder = $record->sortorder;
+        }
+        $this->config       = json_decode($record->configdata);
+        $this->dirty        = false;
+        $this->steps        = [];
+
+        return $this;
+    }
+
+    /**
+     * Fetch all steps in the tour.
+     *
+     * @return  stdClass[]
+     */
+    public function get_steps() {
+        if (empty($this->steps)) {
+            $this->steps = helper::get_steps($this->id);
+        }
+
+        return $this->steps;
+    }
+
+    /**
+     * Count the number of steps in the tour.
+     *
+     * @return  int
+     */
+    public function count_steps() {
+        return count($this->get_steps());
+    }
+
+    /**
+     * The ID of the tour.
+     *
+     * @return  int
+     */
+    public function get_id() {
+        return $this->id;
+    }
+
+    /**
+     * The name of the tour.
+     *
+     * @return  string
+     */
+    public function get_name() {
+        return $this->name;
+    }
+
+    /**
+     * Set the name of the tour to the specified value.
+     *
+     * @param   string      $value      The new name.
+     * @return  $this
+     */
+    public function set_name($value) {
+        $this->name = clean_param($value, PARAM_TEXT);
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * The description associated with the tour.
+     *
+     * @return  string
+     */
+    public function get_description() {
+        return $this->description;
+    }
+
+    /**
+     * Set the description of the tour to the specified value.
+     *
+     * @param   string      $value      The new description.
+     * @return  $this
+     */
+    public function set_description($value) {
+        $this->description = clean_text($value);
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * The path match for the tour.
+     *
+     * @return  string
+     */
+    public function get_pathmatch() {
+        return $this->pathmatch;
+    }
+
+    /**
+     * Set the patchmatch of the tour to the specified value.
+     *
+     * @param   string      $value      The new patchmatch.
+     * @return  $this
+     */
+    public function set_pathmatch($value) {
+        $this->pathmatch = $value;
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * The enabled state of the tour.
+     *
+     * @return  int
+     */
+    public function get_enabled() {
+        return $this->enabled;
+    }
+
+    /**
+     * Whether the tour is currently enabled.
+     *
+     * @return  boolean
+     */
+    public function is_enabled() {
+        return ($this->enabled == self::ENABLED);
+    }
+
+    /**
+     * Set the enabled state of the tour to the specified value.
+     *
+     * @param   boolean     $value      The new state.
+     * @return  $this
+     */
+    public function set_enabled($value) {
+        $this->enabled = $value;
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * The link to view this tour.
+     *
+     * @return  moodle_url
+     */
+    public function get_view_link() {
+        return helper::get_view_tour_link($this->id);
+    }
+
+    /**
+     * The link to edit this tour.
+     *
+     * @return  moodle_url
+     */
+    public function get_edit_link() {
+        return helper::get_edit_tour_link($this->id);
+    }
+
+    /**
+     * The link to reset the state of this tour for all users.
+     *
+     * @return  moodle_url
+     */
+    public function get_reset_link() {
+        return helper::get_reset_tour_for_all_link($this->id);
+    }
+
+    /**
+     * The link to export this tour.
+     *
+     * @return  moodle_url
+     */
+    public function get_export_link() {
+        return helper::get_export_tour_link($this->id);
+    }
+
+    /**
+     * The link to remove this tour.
+     *
+     * @return  moodle_url
+     */
+    public function get_delete_link() {
+        return helper::get_delete_tour_link($this->id);
+    }
+
+    /**
+     * Prepare this tour for saving to the database.
+     *
+     * @return  object
+     */
+    public function to_record() {
+        return (object) array(
+            'id'            => $this->id,
+            'name'          => $this->name,
+            'description'   => $this->description,
+            'pathmatch'     => $this->pathmatch,
+            'enabled'       => $this->enabled,
+            'sortorder'     => $this->sortorder,
+            'configdata'    => json_encode($this->config),
+        );
+    }
+
+    /**
+     * Get the current sortorder for this tour.
+     *
+     * @return  int
+     */
+    public function get_sortorder() {
+        return (int) $this->sortorder;
+    }
+
+    /**
+     * Whether this tour is the first tour.
+     *
+     * @return  boolean
+     */
+    public function is_first_tour() {
+        return ($this->get_sortorder() === 0);
+    }
+
+    /**
+     * Whether this tour is the last tour.
+     *
+     * @param   int         $tourcount  The pre-fetched count of tours
+     * @return  boolean
+     */
+    public function is_last_tour($tourcount = null) {
+        if ($tourcount === null) {
+            $tourcount = helper::count_tours();
+        }
+        return ($this->get_sortorder() === ($tourcount - 1));
+    }
+
+    /**
+     * Set the sortorder for this tour.
+     *
+     * @param   int         $value      The new sortorder to use.
+     * @return  $this
+     */
+    public function set_sortorder($value) {
+        $this->sortorder = $value;
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * Calculate the next sort-order value.
+     *
+     * @return  int
+     */
+    protected function calculate_sortorder() {
+        $this->sortorder = helper::count_tours();
+
+        return $this;
+    }
+
+    /**
+     * Get the link to move this tour up in the sortorder.
+     *
+     * @return  moodle_url
+     */
+    public function get_moveup_link() {
+        return helper::get_move_tour_link($this->get_id(), helper::MOVE_UP);
+    }
+
+    /**
+     * Get the link to move this tour down in the sortorder.
+     *
+     * @return  moodle_url
+     */
+    public function get_movedown_link() {
+        return helper::get_move_tour_link($this->get_id(), helper::MOVE_DOWN);
+    }
+
+    /**
+     * Get the value of the specified configuration item.
+     *
+     * @param   string      $key        The configuration key to set.
+     * @param   mixed       $default    The default value to use if a value was not found.
+     * @return  mixed
+     */
+    public function get_config($key = null, $default = null) {
+        if ($this->config === null) {
+            $this->config = (object) array();
+        }
+        if ($key === null) {
+            return $this->config;
+        }
+
+        if (property_exists($this->config, $key)) {
+            return $this->config->$key;
+        }
+
+        if ($default !== null) {
+            return $default;
+        }
+
+        return configuration::get_default_value($key);
+    }
+
+    /**
+     * Set the configuration item as specified.
+     *
+     * @param   string      $key        The configuration key to set.
+     * @param   mixed       $value      The new value for the configuration item.
+     * @return  $this
+     */
+    public function set_config($key, $value) {
+        if ($this->config === null) {
+            $this->config = (object) array();
+        }
+        $this->config->$key = $value;
+        $this->dirty = true;
+
+        return $this;
+    }
+
+    /**
+     * Save the tour and it's configuration to the database.
+     *
+     * @param   boolean     $force      Whether to force writing to the database.
+     * @return  $this
+     */
+    public function persist($force = false) {
+        global $DB;
+
+        if (!$this->dirty && !$force) {
+            return $this;
+        }
+
+        if ($this->id) {
+            $record = $this->to_record();
+            $DB->update_record('tool_usertours_tours', $record);
+        } else {
+            $this->calculate_sortorder();
+            $record = $this->to_record();
+            unset($record->id);
+            $this->id = $DB->insert_record('tool_usertours_tours', $record);
+        }
+
+        $this->reload();
+
+        return $this;
+    }
+
+    /**
+     * Remove this step.
+     */
+    public function remove() {
+        global $DB;
+
+        if ($this->id === null) {
+            // Nothing to delete - this tour has not been persisted.
+            return null;
+        }
+
+        // Delete all steps associated with this tour.
+        // Note, although they are currently just DB records, there may be other components in the future.
+        foreach ($this->get_steps() as $step) {
+            $step->remove();
+        }
+
+        // Remove the configuration for the tour.
+        $DB->delete_records('tool_usertours_tours', array('id' => $this->id));
+
+        helper::reset_tour_sortorder();
+
+        return null;
+    }
+
+    /**
+     * Reset the sortorder for all steps in the tour.
+     *
+     * @return  $this
+     */
+    public function reset_step_sortorder() {
+        global $DB;
+        $steps = $DB->get_records('tool_usertours_steps', array('tourid' => $this->id), 'sortorder ASC', 'id');
+
+        $index = 0;
+        foreach ($steps as $step) {
+            $DB->set_field('tool_usertours_steps', 'sortorder', $index, array('id' => $step->id));
+            $index++;
+        }
+
+        return $this;
+    }
+
+    /**
+     * Whether this tour should be displayed to the user.
+     *
+     * @return  boolean
+     */
+    public function should_show_for_user() {
+        if (!$this->is_enabled()) {
+            // The tour is disabled - it should not be shown.
+            return false;
+        }
+
+        if ($tourcompletiondate = get_user_preferences(self::TOUR_LAST_COMPLETED_BY_USER . $this->get_id(), null)) {
+            if ($tourresetdate = get_user_preferences(self::TOUR_REQUESTED_BY_USER . $this->get_id(), null)) {
+                if ($tourresetdate >= $tourcompletiondate) {
+                    return true;
+                }
+            }
+            $lastmajorupdate = $this->get_config('majorupdatetime', time());
+            if ($tourcompletiondate > $lastmajorupdate) {
+                // The user has completed the tour since the last major update.
+                return false;
+            }
+        }
+
+        return true;
+    }
+
+    /**
+     * Get the key for this tour.
+     * This is used in the session cookie to determine whether the user has seen this tour before.
+     */
+    public function get_tour_key() {
+        $tourtime = $this->get_config('majorupdatetime', null);
+
+        if ($tourtime === null) {
+            // This tour has no majorupdate time.
+            // Set one now to prevent repeated displays to the user.
+            $this->set_config('majorupdatetime', time());
+            $this->persist();
+            $tourtime = $this->get_config('majorupdatetime', null);
+        }
+
+        if ($userresetdate = get_user_preferences(self::TOUR_REQUESTED_BY_USER . $this->get_id(), null)) {
+            $tourtime = max($tourtime, $userresetdate);
+        }
+
+        return sprintf('tool_usertours_%d_%s', $this->get_id(), $tourtime);
+    }
+
+    /**
+     * Reset the requested by user date.
+     *
+     * @return  $this
+     */
+    public function request_user_reset() {
+        set_user_preference(self::TOUR_REQUESTED_BY_USER . $this->get_id(), time());
+
+        return $this;
+    }
+
+    /**
+     * Mark this tour as completed for this user.
+     *
+     * @return  $this
+     */
+    public function mark_user_completed() {
+        set_user_preference(self::TOUR_LAST_COMPLETED_BY_USER . $this->get_id(), time());
+
+        return $this;
+    }
+
+    /**
+     * Update a tour giving it a new major update time.
+     * This will ensure that it is displayed to all users, even those who have already seen it.
+     *
+     * @return  $this
+     */
+    public function mark_major_change() {
+        global $DB;
+
+        // Clear old reset and completion notes.
+        $DB->delete_records('user_preferences', ['name' => self::TOUR_LAST_COMPLETED_BY_USER . $this->get_id()]);
+        $DB->delete_records('user_preferences', ['name' => self::TOUR_REQUESTED_BY_USER . $this->get_id()]);
+        $this->set_config('majorupdatetime', time());
+        $this->persist();
+
+        return $this;
+    }
+
+    /**
+     * Add the step configuration to the form.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     * @return  $this
+     */
+    public function add_config_to_form(\MoodleQuickForm &$mform) {
+        $options = configuration::get_placement_options();
+        $mform->addElement('select', 'placement', get_string('placement', 'tool_usertours'), $options);
+        $mform->addHelpButton('placement', 'placement', 'tool_usertours');
+
+        $this->add_config_field_to_form($mform, 'orphan');
+        $this->add_config_field_to_form($mform, 'backdrop');
+        $this->add_config_field_to_form($mform, 'reflex');
+
+        return $this;
+    }
+
+    /**
+     * Add the specified step field configuration to the form.
+     *
+     * @param   MoodleQuickForm $mform      The form to add configuration to.
+     * @param   string          $key        The key to add.
+     * @return  $this
+     */
+    protected function add_config_field_to_form(\MoodleQuickForm &$mform, $key) {
+        $options = [
+            true    => get_string('yes'),
+            false   => get_string('no'),
+        ];
+        $mform->addElement('select', $key, get_string($key, 'tool_usertours'), $options);
+        $mform->setDefault($key, configuration::get_default_value($key));
+        $mform->addHelpButton($key, $key, 'tool_usertours');
+
+        return $this;
+    }
+
+    /**
+     * Prepare the configuration data for the moodle form.
+     *
+     * @return  object
+     */
+    public function prepare_data_for_form() {
+        $data = $this->to_record();
+        foreach (configuration::get_defaultable_keys() as $key) {
+            $data->$key = $this->get_config($key, configuration::get_default_value($key));
+        }
+
+        return $data;
+    }
+
+    /**
+     * Get the configured filter values.
+     *
+     * @param   string      $filter     The filter to retrieve values for.
+     * @return  array
+     */
+    public function get_filter_values($filter) {
+        if ($allvalues = (array) $this->get_config('filtervalues')) {
+            if (isset($allvalues[$filter])) {
+                return $allvalues[$filter];
+            }
+        }
+
+        return [];
+    }
+
+    /**
+     * Set the values for the specified filter.
+     *
+     * @param   string      $filter     The filter to set.
+     * @param   array       $values     The values to set.
+     * @return  $this
+     */
+    public function set_filter_values($filter, array $values = []) {
+        $allvalues = (array) $this->get_config('filtervalues', []);
+        $allvalues[$filter] = $values;
+
+        return $this->set_config('filtervalues', $allvalues);
+    }
+
+    /**
+     * Check whether this tour matches all filters.
+     *
+     * @param   context     $context    The context to check
+     * @return  bool
+     */
+    public function matches_all_filters(\context $context) {
+        $filters = helper::get_all_filters();
+
+        // All filters must match.
+        // If any one filter fails to match, we return false.
+        foreach ($filters as $filterclass) {
+            if (!$filterclass::filter_matches($this, $context)) {
+                return false;
+            }
+        }
+
+        return true;
+    }
+}
diff --git a/admin/tool/usertours/configure.php b/admin/tool/usertours/configure.php
new file mode 100644 (file)
index 0000000..cb7bc98
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Configuration page.
+ *
+ * @package   tool_usertours
+ * @copyright 2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../../../config.php');
+require_once($CFG->libdir . '/adminlib.php');
+
+$action = optional_param('action', \tool_usertours\manager::ACTION_LISTTOURS, PARAM_ALPHANUMEXT);
+
+$pluginmanager = new \tool_usertours\manager();
+$PAGE->set_context(context_system::instance());
+
+$pluginmanager->execute(
+        $action
+    );
diff --git a/admin/tool/usertours/db/access.php b/admin/tool/usertours/db/access.php
new file mode 100644 (file)
index 0000000..1e8e6b9
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Plugin capabilities.
+ *
+ * @package   tool_usertours
+ * @copyright 2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$capabilities = array(
+    'tool/usertours:managetours' => [
+        'captype' => 'write',
+        'riskbitmask' => RISK_XSS,
+        'contextlevel' => CONTEXT_SYSTEM,
+        'archetypes' => [
+            'manager' => CAP_ALLOW,
+        ]
+    ],
+);
diff --git a/admin/tool/usertours/db/install.php b/admin/tool/usertours/db/install.php
new file mode 100644 (file)
index 0000000..3b6ab9d
--- /dev/null
@@ -0,0 +1,74 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Install code for tours.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Perform the post-install procedures.
+ */
+function xmldb_tool_usertours_install() {
+    global $DB;
+
+    $localplugin = core_plugin_manager::instance()->get_plugin_info('local_usertours');
+    if ($localplugin) {
+        // If the old local plugin was previously installed, copy over the data from the old tables.
+
+        // The 'comment' field was renamed to 'description' in:
+        // * 3.0 version 2015111604
+        // * 3.1 version 2016052303
+        // We need to attempt to fetch comment for these older versions.
+        $hasdescription = ($localplugin->versiondb < 2016052301 && $localplugin->versiondb >= 2015111604);
+        $hasdescription = $hasdescription || ($localplugin->versiondb > 2016052303);
+
+        $tours = $DB->get_recordset('usertours_tours');
+        $mapping = [];
+        foreach ($tours as $tour) {
+            if (!$hasdescription) {
+                if (property_exists($tour, 'comment')) {
+                    $tour->description = $tour->comment;
+                    unset($tour->comment);
+                } else {
+                    $tour->description = '';
+                }
+            }
+            $mapping[$tour->id] = $DB->insert_record('tool_usertours_tours', $tour);
+        }
+        $tours->close();
+
+        $steps = $DB->get_recordset('usertours_steps');
+        foreach ($steps as $step) {
+            if (!isset($mapping[$step->tourid])) {
+                // Skip this one. It has somehow become orphaned.
+                continue;
+            }
+            $step->tourid = $mapping[$step->tourid];
+            $DB->insert_record('tool_usertours_steps', $step);
+        }
+        $steps->close();
+
+        // Delete the old records.
+        $DB->delete_records('usertours_steps', null);
+        $DB->delete_records('usertours_tours', null);
+    }
+}
diff --git a/admin/tool/usertours/db/install.xml b/admin/tool/usertours/db/install.xml
new file mode 100644 (file)
index 0000000..3402bd8
--- /dev/null
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<XMLDB PATH="tool/usertours/db" VERSION="20160830" COMMENT="XMLDB file for Moodle tool/usertours"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
+>
+  <TABLES>
+    <TABLE NAME="tool_usertours_tours" COMMENT="List of tours">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="name" TYPE="char" LENGTH="255" NOTNULL="true" SEQUENCE="false" COMMENT="Name of the user tour"/>
+        <FIELD NAME="description" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="pathmatch" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="enabled" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="sortorder" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="configdata" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+      </KEYS>
+    </TABLE>
+    <TABLE NAME="tool_usertours_steps" COMMENT="Steps in an tour">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="tourid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="title" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Title of the step"/>
+        <FIELD NAME="content" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Content of the user tour - allow for multilang tags"/>
+        <FIELD NAME="targettype" TYPE="int" LENGTH="2" NOTNULL="true" SEQUENCE="false" COMMENT="Type of the target (e.g. block, CSS selector, etc.)"/>
+        <FIELD NAME="targetvalue" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="The value for the specified target type."/>
+        <FIELD NAME="sortorder" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="configdata" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="tourid-tour" TYPE="foreign" FIELDS="tourid" REFTABLE="tool_usertours_tours" REFFIELDS="id"/>
+      </KEYS>
+      <INDEXES>
+        <INDEX NAME="orderedsteps" UNIQUE="false" FIELDS="tourid, sortorder"/>
+      </INDEXES>
+    </TABLE>
+  </TABLES>
+</XMLDB>
diff --git a/admin/tool/usertours/db/services.php b/admin/tool/usertours/db/services.php
new file mode 100644 (file)
index 0000000..38e65ea
--- /dev/null
@@ -0,0 +1,63 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * List of Web Services for the tool_usertours plugin.
+ *
+ * @package    tool_usertours
+ * @copyright  2016 Andrew Nicols
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$functions = array(
+    'tool_usertours_fetch_and_start_tour' => array(
+        'classname'       => 'tool_usertours\external\tour',
+        'methodname'      => 'fetch_and_start_tour',
+        'description'     => 'Fetch the specified tour',
+        'type'            => 'read',
+        'capabilities'    => '',
+        'ajax'            => true,
+    ),
+
+    'tool_usertours_step_shown' => array(
+        'classname'       => 'tool_usertours\external\tour',
+        'methodname'      => 'step_shown',
+        'description'     => 'Mark the specified step as completed for the current user',
+        'type'            => 'write',
+        'capabilities'    => '',
+        'ajax'            => true,
+    ),
+
+    'tool_usertours_complete_tour' => array(
+        'classname'       => 'tool_usertours\external\tour',
+        'methodname'      => 'complete_tour',
+        'description'     => 'Mark the specified tour as completed for the current user',
+        'type'            => 'write',
+        'capabilities'    => '',
+        'ajax'            => true,
+    ),
+
+    'tool_usertours_reset_tour' => array(
+        'classname'       => 'tool_usertours\external\tour',
+        'methodname'      => 'reset_tour',
+        'description'     => 'Remove the specified tour',
+        'type'            => 'write',
+        'capabilities'    => '',
+        'ajax'            => true,
+    ),
+);
diff --git a/admin/tool/usertours/lang/en/tool_usertours.php b/admin/tool/usertours/lang/en/tool_usertours.php
new file mode 100644 (file)
index 0000000..9d3fae6
--- /dev/null
@@ -0,0 +1,162 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle 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, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle 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 Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Strings for tool_usertours.
+ *
+ * @package   tool_usertours
+ * @copyright 2016 Andrew Nicols <andrew@nicols.co.uk>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['actions'] = 'Actions';
+$string['appliesto'] = 'Applies to';
+$string['block'] = 'Block';
+$string['block_named'] = 'Block named \'{$a}\'';
+$string['bottom'] = 'Bottom';
+$string['description'] = 'Description';
+$string['confirmstepremovalquestion'] = 'Are you sure that you wish to remove this step?';
+$string['confirmstepremovaltitle'] = 'Confirm step removal';
+$string['confirmtourremovalquestion'] = 'Are you sure that you wish to remove this tour?';
+$string['confirmtourremovaltitle'] = 'Confirm tour removal';
+$string['content'] = 'Content';
+$string['content_help'] = 'This is the content of the step.
+You can enter a content in the following formats:
+<dl>
+    <dt>Plain text</dt>
+    <dd>A plain text description</dd>
+    <dt>Moodle MultiLang</dt>
+    <dd>A string which makes use of the Moodle MultiLang format</dd>
+    <dt>Moodle Translated string</dt>
+    <dd>A value found in a standard Moodle language file in the format identifier,component</dd>
+</dl>';
+$string['cssselector'] = 'CSS Selector';
+$string['defaultvalue'] = 'Default ({$a})';
+$string['delay'] = 'Delay before showing the step';
+$string['done'] = 'Done';
+$string['editstep'] = 'Editing "{$a}"';
+$string['enabled'] = 'Enabled';
+$string['event_tour_started'] = 'Tour started';
+$string['event_tour_reset'] = 'Tour reset';
+$string['event_tour_ended'] = 'Tour ended';
+$string['event_step_shown'] = 'Step shown';
+$string['exporttour'] = 'Export tour';
+$string['filter_header'] = 'Tour filters';
+$string['filter_help'] = 'Your can choose which conditions your tour will be shown under.
+All of the filters must match for a tour to be shown to that user.';
+$string['filter_theme'] = 'Theme';
+$string['filter_theme_help'] = 'Show the tour when the user is using one of the selected themes.';
+$string['filter_role'] = 'Role';
+$string['filter_role_help'] = 'Only show the tour to users with one of the specified roles.';
+$string['importtour'] = 'Import tour';
+$string['left'] = 'Left';
+$string['movestepdown'] = 'Move step down';
+$string['movestepup'] = 'Move step up';
+$string['movetourdown'] = 'Move tour down';
+$string['movetourup'] = 'Move tour up';
+$string['name'] = 'Name';
+$string['newstep'] = 'Create step';
+$string['newstep'] = 'New step';
+$string['newtour'] = 'Create a new tour';
+$string['next'] = 'Next';
+$string['pathmatch'] = 'Apply to URL match';
+$string['pathmatch_help'] = 'Tours will be displayed on any page whose URL matches this value.
+
+You can use the % character as a wildcard to mean anything.
+Some example values include:
+
+* /my/% - to match the Dashboard
+* /course/view.php?id=2 - to match a specific course
+* /mod/forum/view.php% - to match the forum discussion list
+* /user/profile.php% - to match the user profile page';
+$string['placement'] = 'Placement';
+$string['pluginname'] = 'User Tours';
+$string['resettouronpage'] = 'Reset user tour on this page';
+$string['right'] = 'Right';
+$string['select_block'] = 'Select a block';
+$string['select_targettype'] = 'Every step is associated with a part of the page which you must choose. To make this easier there are several types of target for different types of page content.
+<dl>
+    <dt>Block</dt>
+    <dd>Display the step next to the first matching block of the type on the page</dd>
+    <dt>Selector</dt>
+    <dd>CSS Selectors are a powerful way which allow you to select different parts of the page based on metadata built into the page.</dd>
+    <dt>Display in middle of the page</dt>
+    <dd>Instead of associating the step with a specific part of the page you can have it displayed in the middle of the page.</dd>
+</dl>';
+$string['selector_defaulttitle'] = 'Enter a descriptive title';
+$string['selectordisplayname'] = 'A CSS selector matching \'{$a}\'';
+$string['skip'] = 'Skip';
+$string['target'] = 'Target';
+$string['target_block'] = 'Block';
+$string['target_selector'] = 'Selector';
+$string['target_unattached'] = 'Display in middle of page';
+$string['targettype'] = 'Target type';
+$string['title'] = 'Title';
+$string['title_help'] = 'This is the title shown at the top of the step.
+You can enter a title in the following formats:
+<dl>
+    <dt>Plain text</dt>
+    <dd>A plain text description</dd>
+    <dt>Moodle MultiLang</dt>
+    <dd>A string which makes use of the Moodle MultiLang format</dd>
+    <dt>Moodle Translated string</dt>
+    <dd>A value found in a standard Moodle language file in the format identifier,component</dd>
+</dl>';
+$string['top'] = 'Top';
+$string['tourconfig'] = 'Tour configuration file to import';
+$string['tourlist_explanation'] = 'You can create as many tours as you like and enable them for different parts of Moodle. Only one tour can be created per page.';
+$string['tours'] = 'Tours';
+$string['pausetour'] = 'Pause';
+$string['resumetour'] = 'Resume';
+$string['endtour'] = 'End tour';
+$string['orphan'] = 'Show if target not found';
+$string['orphan_help'] = 'Show the step if the target could not be found on the page.';
+$string['backdrop'] = 'Show with backdrop';
+$string['backdrop_help'] = 'You can use a backdrop to highlight the part of the page that you are pointing to.
+
+Note: Backdrops are not compatible with some parts of the page such as the navigation bar.
+';
+$string['reflex'] = 'Move on click';
+$string['reflex_help'] = 'Move on to the next step when the target is clicked on.';
+$string['placement_help'] = 'You can place a step either above, below, to the left of, or to the right of the target.
+
+The best options are top, or bottom as these adjust better for mobile display.';
+$string['delay_help'] = 'You can optionally choose to add a delay before the step is displayed.
+
+This delay is in milliseconds.';
+$string['selecttype'] = 'Select step type';
+$string['sharedtourslink'] = 'Tour repository';
+$string['usertours'] = 'User tours';
+$string['usertours:managetours'] = 'Create, edit, and remove user tours';
+$string['target_selector_targetvalue'] = '