From 001fc061bd097499b03ca5651742785cf713d7d1 Mon Sep 17 00:00:00 2001 From: Andrew Nicols Date: Sat, 1 Oct 2016 21:24:29 +0800 Subject: [PATCH] MDL-52777 tool_usertours: Add the new User Tours plugin --- .eslintignore | 2 + .stylelintignore | 2 + .../usertours/amd/build/managesteps.min.js | 1 + .../usertours/amd/build/managetours.min.js | 1 + admin/tool/usertours/amd/build/popper.min.js | 1 + admin/tool/usertours/amd/build/tour.min.js | 1 + .../tool/usertours/amd/build/usertours.min.js | 1 + admin/tool/usertours/amd/readme_moodle.txt | 17 + admin/tool/usertours/amd/src/managesteps.js | 64 + admin/tool/usertours/amd/src/managetours.js | 64 + admin/tool/usertours/amd/src/popper.js | 1325 ++++++++++++++++ admin/tool/usertours/amd/src/tour.js | 1381 +++++++++++++++++ admin/tool/usertours/amd/src/usertours.js | 238 +++ .../tool/usertours/classes/configuration.php | 118 ++ .../usertours/classes/event/step_shown.php | 146 ++ .../usertours/classes/event/tour_ended.php | 145 ++ .../usertours/classes/event/tour_reset.php | 132 ++ .../usertours/classes/event/tour_started.php | 131 ++ .../tool/usertours/classes/external/tour.php | 330 ++++ admin/tool/usertours/classes/helper.php | 523 +++++++ .../usertours/classes/local/filter/base.php | 135 ++ .../usertours/classes/local/filter/role.php | 99 ++ .../usertours/classes/local/filter/theme.php | 102 ++ .../classes/local/forms/editstep.php | 96 ++ .../classes/local/forms/edittour.php | 95 ++ .../classes/local/forms/importtour.php | 56 + .../classes/local/table/step_list.php | 143 ++ .../classes/local/table/tour_list.php | 150 ++ .../usertours/classes/local/target/base.php | 119 ++ .../usertours/classes/local/target/block.php | 115 ++ .../classes/local/target/selector.php | 107 ++ .../classes/local/target/unattached.php | 100 ++ admin/tool/usertours/classes/manager.php | 769 +++++++++ .../usertours/classes/output/renderer.php | 36 + admin/tool/usertours/classes/output/step.php | 121 ++ admin/tool/usertours/classes/output/tour.php | 71 + admin/tool/usertours/classes/step.php | 629 ++++++++ admin/tool/usertours/classes/target.php | 128 ++ admin/tool/usertours/classes/tour.php | 761 +++++++++ admin/tool/usertours/configure.php | 35 + admin/tool/usertours/db/access.php | 36 + admin/tool/usertours/db/install.php | 74 + admin/tool/usertours/db/install.xml | 41 + admin/tool/usertours/db/services.php | 63 + .../tool/usertours/lang/en/tool_usertours.php | 162 ++ admin/tool/usertours/lib.php | 69 + admin/tool/usertours/pix/b/tour-import.png | Bin 0 -> 1734 bytes admin/tool/usertours/pix/b/tour-new.png | Bin 0 -> 2485 bytes admin/tool/usertours/pix/b/tour-shared.png | Bin 0 -> 3263 bytes admin/tool/usertours/pix/i/reload.png | Bin 0 -> 257 bytes admin/tool/usertours/pix/i/reload.svg | 3 + admin/tool/usertours/pix/i/sprite-green.png | Bin 0 -> 5405 bytes admin/tool/usertours/pix/i/sprite-orange.png | Bin 0 -> 5374 bytes admin/tool/usertours/pix/t/export.png | Bin 0 -> 266 bytes admin/tool/usertours/pix/t/export.svg | 16 + admin/tool/usertours/pix/t/filler.svg | 12 + admin/tool/usertours/settings.php | 40 + admin/tool/usertours/styles.css | 68 + admin/tool/usertours/styles_bootstrapbase.css | 0 .../usertours/templates/selecttarget.mustache | 60 + .../usertours/templates/tourstep.mustache | 62 + .../tests/behat/behat_tool_usertours.php | 92 ++ .../usertours/tests/behat/create_tour.feature | 81 + .../usertours/tests/behat/tour_filter.feature | 62 + admin/tool/usertours/tests/manager_test.php | 280 ++++ .../tool/usertours/tests/role_filter_test.php | 188 +++ .../tool/usertours/tests/step_output_test.php | 84 + admin/tool/usertours/tests/step_test.php | 806 ++++++++++ .../usertours/tests/theme_filter_test.php | 100 ++ admin/tool/usertours/tests/tour_test.php | 1111 +++++++++++++ admin/tool/usertours/thirdpartylibs.xml | 17 + admin/tool/usertours/version.php | 29 + lib/classes/plugin_manager.php | 2 +- theme/boost/scss/moodle.scss | 1 + theme/boost/scss/moodle/modules.scss | 2 +- theme/boost/scss/moodle/tool_usertours.scss | 105 ++ .../tool_usertours/tourstep.mustache | 65 + theme/bootstrapbase/less/moodle.less | 1 + .../less/moodle/tool_usertours.less | 146 ++ theme/bootstrapbase/style/moodle.css | 146 ++ 80 files changed, 12512 insertions(+), 2 deletions(-) create mode 100644 admin/tool/usertours/amd/build/managesteps.min.js create mode 100644 admin/tool/usertours/amd/build/managetours.min.js create mode 100644 admin/tool/usertours/amd/build/popper.min.js create mode 100644 admin/tool/usertours/amd/build/tour.min.js create mode 100644 admin/tool/usertours/amd/build/usertours.min.js create mode 100644 admin/tool/usertours/amd/readme_moodle.txt create mode 100644 admin/tool/usertours/amd/src/managesteps.js create mode 100644 admin/tool/usertours/amd/src/managetours.js create mode 100644 admin/tool/usertours/amd/src/popper.js create mode 100644 admin/tool/usertours/amd/src/tour.js create mode 100644 admin/tool/usertours/amd/src/usertours.js create mode 100644 admin/tool/usertours/classes/configuration.php create mode 100644 admin/tool/usertours/classes/event/step_shown.php create mode 100644 admin/tool/usertours/classes/event/tour_ended.php create mode 100644 admin/tool/usertours/classes/event/tour_reset.php create mode 100644 admin/tool/usertours/classes/event/tour_started.php create mode 100644 admin/tool/usertours/classes/external/tour.php create mode 100644 admin/tool/usertours/classes/helper.php create mode 100644 admin/tool/usertours/classes/local/filter/base.php create mode 100644 admin/tool/usertours/classes/local/filter/role.php create mode 100644 admin/tool/usertours/classes/local/filter/theme.php create mode 100644 admin/tool/usertours/classes/local/forms/editstep.php create mode 100644 admin/tool/usertours/classes/local/forms/edittour.php create mode 100644 admin/tool/usertours/classes/local/forms/importtour.php create mode 100644 admin/tool/usertours/classes/local/table/step_list.php create mode 100644 admin/tool/usertours/classes/local/table/tour_list.php create mode 100644 admin/tool/usertours/classes/local/target/base.php create mode 100644 admin/tool/usertours/classes/local/target/block.php create mode 100644 admin/tool/usertours/classes/local/target/selector.php create mode 100644 admin/tool/usertours/classes/local/target/unattached.php create mode 100644 admin/tool/usertours/classes/manager.php create mode 100644 admin/tool/usertours/classes/output/renderer.php create mode 100644 admin/tool/usertours/classes/output/step.php create mode 100644 admin/tool/usertours/classes/output/tour.php create mode 100644 admin/tool/usertours/classes/step.php create mode 100644 admin/tool/usertours/classes/target.php create mode 100644 admin/tool/usertours/classes/tour.php create mode 100644 admin/tool/usertours/configure.php create mode 100644 admin/tool/usertours/db/access.php create mode 100644 admin/tool/usertours/db/install.php create mode 100644 admin/tool/usertours/db/install.xml create mode 100644 admin/tool/usertours/db/services.php create mode 100644 admin/tool/usertours/lang/en/tool_usertours.php create mode 100644 admin/tool/usertours/lib.php create mode 100644 admin/tool/usertours/pix/b/tour-import.png create mode 100644 admin/tool/usertours/pix/b/tour-new.png create mode 100644 admin/tool/usertours/pix/b/tour-shared.png create mode 100644 admin/tool/usertours/pix/i/reload.png create mode 100644 admin/tool/usertours/pix/i/reload.svg create mode 100644 admin/tool/usertours/pix/i/sprite-green.png create mode 100644 admin/tool/usertours/pix/i/sprite-orange.png create mode 100644 admin/tool/usertours/pix/t/export.png create mode 100644 admin/tool/usertours/pix/t/export.svg create mode 100644 admin/tool/usertours/pix/t/filler.svg create mode 100644 admin/tool/usertours/settings.php create mode 100644 admin/tool/usertours/styles.css create mode 100644 admin/tool/usertours/styles_bootstrapbase.css create mode 100644 admin/tool/usertours/templates/selecttarget.mustache create mode 100644 admin/tool/usertours/templates/tourstep.mustache create mode 100644 admin/tool/usertours/tests/behat/behat_tool_usertours.php create mode 100644 admin/tool/usertours/tests/behat/create_tour.feature create mode 100644 admin/tool/usertours/tests/behat/tour_filter.feature create mode 100644 admin/tool/usertours/tests/manager_test.php create mode 100644 admin/tool/usertours/tests/role_filter_test.php create mode 100644 admin/tool/usertours/tests/step_output_test.php create mode 100644 admin/tool/usertours/tests/step_test.php create mode 100644 admin/tool/usertours/tests/theme_filter_test.php create mode 100644 admin/tool/usertours/tests/tour_test.php create mode 100644 admin/tool/usertours/thirdpartylibs.xml create mode 100644 admin/tool/usertours/version.php create mode 100644 theme/boost/scss/moodle/tool_usertours.scss create mode 100644 theme/boost/templates/tool_usertours/tourstep.mustache create mode 100644 theme/bootstrapbase/less/moodle/tool_usertours.less diff --git a/.eslintignore b/.eslintignore index 73b2b4a262b..ba9d5299ff3 100644 --- a/.eslintignore +++ b/.eslintignore @@ -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/ diff --git a/.stylelintignore b/.stylelintignore index b9e8b2c8e8a..4959447d53c 100644 --- a/.stylelintignore +++ b/.stylelintignore @@ -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 index 00000000000..886c25024b6 --- /dev/null +++ b/admin/tool/usertours/amd/build/managesteps.min.js @@ -0,0 +1 @@ +define(["jquery","core/str","core/notification"],function(a,b,c){var d={removeStep:function(d){d.preventDefault(),b.get_strings([{key:"confirmstepremovaltitle",component:"tool_usertours"},{key:"confirmstepremovalquestion",component:"tool_usertours"},{key:"yes",component:"moodle"},{key:"no",component:"moodle"}]).done(function(b){c.confirm(b[0],b[1],b[2],b[3],a.proxy(function(){window.location=a(this).attr("href")},d.currentTarget))})},setup:function(){a("body").delegate('[data-action="delete"]',"click",d.removeStep)}};return{setup:d.setup}}); \ No newline at end of file diff --git a/admin/tool/usertours/amd/build/managetours.min.js b/admin/tool/usertours/amd/build/managetours.min.js new file mode 100644 index 00000000000..9c9e2e63298 --- /dev/null +++ b/admin/tool/usertours/amd/build/managetours.min.js @@ -0,0 +1 @@ +define(["jquery","core/ajax","core/str","core/notification"],function(a,b,c,d){var e={removeTour:function(b){b.preventDefault(),c.get_strings([{key:"confirmtourremovaltitle",component:"tool_usertours"},{key:"confirmtourremovalquestion",component:"tool_usertours"},{key:"yes",component:"moodle"},{key:"no",component:"moodle"}]).done(function(c){d.confirm(c[0],c[1],c[2],c[3],a.proxy(function(){window.location=a(this).attr("href")},b.currentTarget))})},setup:function(){a("body").delegate('[data-action="delete"]',"click",e.removeTour)}};return{setup:e.setup}}); \ No newline at end of file diff --git a/admin/tool/usertours/amd/build/popper.min.js b/admin/tool/usertours/amd/build/popper.min.js new file mode 100644 index 00000000000..ac29055e2f9 --- /dev/null +++ b/admin/tool/usertours/amd/build/popper.min.js @@ -0,0 +1 @@ +!function(a,b){"function"==typeof define&&define.amd?define(b):"object"==typeof module&&module.exports?module.exports=b():a.Popper=b()}(this,function(){"use strict";function a(a,b,c){this._reference=a.jquery?a[0]:a,this.state={onCreateCalled:!1};var d="undefined"==typeof b||null===b,e=b&&"[object Object]"===Object.prototype.toString.call(b);return d||e?this._popper=this.parse(e?b:{}):this._popper=b.jquery?b[0]:b,this._options=Object.assign({},r,c),this._options.modifiers=this._options.modifiers.map(function(a){if(this._options.modifiersIgnored.indexOf(a)===-1)return"applyStyle"===a&&this._popper.setAttribute("x-placement",this._options.placement),this.modifiers[a]||a}.bind(this)),this.state.position=this._getPosition(this._popper,this._reference),k(this._popper,{position:this.state.position}),this.state.isParentTransformed=this._getIsParentTransformed(this._popper),this.update(),this._setupEventListeners(),this}function b(a){var b=a.style.display,c=a.style.visibility;a.style.display="block",a.style.visibility="hidden";var d=(a.offsetWidth,q.getComputedStyle(a)),e=parseFloat(d.marginTop)+parseFloat(d.marginBottom),f=parseFloat(d.marginLeft)+parseFloat(d.marginRight),g={width:a.offsetWidth+f,height:a.offsetHeight+e};return a.style.display=b,a.style.visibility=c,g}function c(a){var b={left:"right",right:"left",bottom:"top",top:"bottom"};return a.replace(/left|right|bottom|top/g,function(a){return b[a]})}function d(a){var b=Object.assign({},a);return b.right=b.left+b.width,b.bottom=b.top+b.height,b}function e(a,b){var c,d=0;for(c in a){if(a[c]===b)return d;d++}return null}function f(a,b){var c=q.getComputedStyle(a,null);return c[b]}function g(a){var b=a.offsetParent;return b!==q.document.body&&b?b:q.document.documentElement}function h(a){return a===q.document?q.document.body.scrollTop?q.document.body:q.document.documentElement:["scroll","auto"].indexOf(f(a,"overflow"))!==-1||["scroll","auto"].indexOf(f(a,"overflow-x"))!==-1||["scroll","auto"].indexOf(f(a,"overflow-y"))!==-1?a===q.document.body?h(a.parentNode):a:a.parentNode?h(a.parentNode):a}function i(a){return a!==q.document.body&&"HTML"!==a.nodeName&&("fixed"===f(a,"position")||(a.parentNode?i(a.parentNode):a))}function j(a){return a!==q.document.body&&("none"!==f(a,"transform")||(a.parentNode?j(a.parentNode):a))}function k(a,b){function c(a){return""!==a&&!isNaN(parseFloat(a))&&isFinite(a)}Object.keys(b).forEach(function(d){var e="";["width","height","top","right","bottom","left"].indexOf(d)!==-1&&c(b[d])&&(e="px"),a.style[d]=b[d]+e})}function l(a){var b={};return a&&"[object Function]"===b.toString.call(a)}function m(a){var b={width:a.offsetWidth,height:a.offsetHeight,left:a.offsetLeft,top:a.offsetTop};return b.right=b.left+b.width,b.bottom=b.top+b.height,b}function n(a){var b=a.getBoundingClientRect();return{left:b.left,top:b.top,right:b.right,bottom:b.bottom,width:b.right-b.left,height:b.bottom-b.top}}function o(a,b,c,d){var e=n(a),f=n(b);if(c&&!d){var g=h(b);f.top+=g.scrollTop,f.bottom+=g.scrollTop,f.left+=g.scrollLeft,f.right+=g.scrollLeft}var i={top:e.top-f.top,left:e.left-f.left,bottom:e.top-f.top+e.height,right:e.left-f.left+e.width,width:e.width,height:e.height};return i}function p(a){for(var b=["","ms","webkit","moz","o"],c=0;c1&&console.warn("WARNING: the given `parent` query("+a.parent+") matched more than one element, the first one will be used"),0===h.length)throw"ERROR: the given `parent` doesn't exists!";h=h[0]}return h.length>1&&h instanceof Element==!1&&(console.warn("WARNING: you have passed as parent a list of elements, the first one will be used"),h=h[0]),h.appendChild(f),f},a.prototype._getPosition=function(a,b){var c=g(b),d=i(c);return d?"fixed":"absolute"},a.prototype._getIsParentTransformed=function(a){return j(a.parentNode)},a.prototype._getOffsets=function(a,c,d){d=d.split("-")[0];var e={};e.position=this.state.position;var f="fixed"===e.position,h=this.state.isParentTransformed,i=g(f&&h?c:a),j=o(c,i,f,h),k=b(a);return["right","left"].indexOf(d)!==-1?(e.top=j.top+j.height/2-k.height/2,"left"===d?e.left=j.left-k.width:e.left=j.right):(e.left=j.left+j.width/2-k.width/2,"top"===d?e.top=j.top-k.height:e.top=j.bottom),e.width=k.width,e.height=k.height,{popper:e,reference:j}},a.prototype._setupEventListeners=function(){if(this.state.updateBound=this.update.bind(this),q.addEventListener("resize",this.state.updateBound),"window"!==this._options.boundariesElement){var a=h(this._reference);a!==q.document.body&&a!==q.document.documentElement||(a=q),a.addEventListener("scroll",this.state.updateBound)}},a.prototype._removeEventListeners=function(){if(q.removeEventListener("resize",this.state.updateBound),"window"!==this._options.boundariesElement){var a=h(this._reference);a!==q.document.body&&a!==q.document.documentElement||(a=q),a.removeEventListener("scroll",this.state.updateBound)}this.state.updateBound=null},a.prototype._getBoundaries=function(a,b,c){var d,e,f={};if("window"===c){var i=q.document.body,j=q.document.documentElement;e=Math.max(i.scrollHeight,i.offsetHeight,j.clientHeight,j.scrollHeight,j.offsetHeight),d=Math.max(i.scrollWidth,i.offsetWidth,j.clientWidth,j.scrollWidth,j.offsetWidth),f={top:0,right:d,bottom:e,left:0}}else if("viewport"===c){var k=g(this._popper),l=h(this._popper),n=m(k),o="fixed"===a.offsets.popper.position?0:l.scrollTop,p="fixed"===a.offsets.popper.position?0:l.scrollLeft;f={top:0-(n.top-o),right:q.document.documentElement.clientWidth-(n.left-p),bottom:q.document.documentElement.clientHeight-(n.top-o),left:0-(n.left-p)}}else f=g(this._popper)===c?{top:0,left:0,right:c.clientWidth,bottom:c.clientHeight}:m(c);return f.left+=b,f.right-=b,f.top=f.top+b,f.bottom=f.bottom-b,f},a.prototype.runModifiers=function(a,b,c){var d=b.slice();return void 0!==c&&(d=this._options.modifiers.slice(0,e(this._options.modifiers,c))),d.forEach(function(b){l(b)&&(a=b.call(this,a))}.bind(this)),a},a.prototype.isModifierRequired=function(a,b){var c=e(this._options.modifiers,a);return!!this._options.modifiers.slice(0,c).filter(function(a){return a===b}).length},a.prototype.modifiers={},a.prototype.modifiers.applyStyle=function(a){var b,c={position:a.offsets.popper.position},d=Math.round(a.offsets.popper.left),e=Math.round(a.offsets.popper.top);return this._options.gpuAcceleration&&(b=p("transform"))?(c[b]="translate3d("+d+"px, "+e+"px, 0)",c.top=0,c.left=0):(c.left=d,c.top=e),Object.assign(c,a.styles),k(this._popper,c),this._popper.setAttribute("x-placement",a.placement),a.offsets.arrow&&k(a.arrowElement,a.offsets.arrow),a},a.prototype.modifiers.shift=function(a){var b=a.placement,c=b.split("-")[0],e=b.split("-")[1];if(e){var f=a.offsets.reference,g=d(a.offsets.popper),h={y:{start:{top:f.top},end:{top:f.top+f.height-g.height}},x:{start:{left:f.left},end:{left:f.left+f.width-g.width}}},i=["bottom","top"].indexOf(c)!==-1?"x":"y";a.offsets.popper=Object.assign(g,h[i][e])}return a},a.prototype.modifiers.preventOverflow=function(a){var b=this._options.preventOverflowOrder,c=d(a.offsets.popper),e={left:function(){var b=c.left;return c.lefta.boundaries.right&&(b=Math.min(c.left,a.boundaries.right-c.width)),{left:b}},top:function(){var b=c.top;return c.topa.boundaries.bottom&&(b=Math.min(c.top,a.boundaries.bottom-c.height)),{top:b}}};return b.forEach(function(b){a.offsets.popper=Object.assign(c,e[b]())}),a},a.prototype.modifiers.keepTogether=function(a){var b=d(a.offsets.popper),c=a.offsets.reference,e=Math.floor;return b.righte(c.right)&&(a.offsets.popper.left=e(c.right)),b.bottome(c.bottom)&&(a.offsets.popper.top=e(c.bottom)),a},a.prototype.modifiers.flip=function(a){if(!this.isModifierRequired(this.modifiers.flip,this.modifiers.preventOverflow))return console.warn("WARNING: preventOverflow modifier is required by flip modifier in order to work, be sure to include it before flip!"),a;if(a.flipped&&a.placement===a._originalPlacement)return a;var b=a.placement.split("-")[0],e=c(b),f=a.placement.split("-")[1]||"",g=[];return g="flip"===this._options.flipBehavior?[b,e]:this._options.flipBehavior,g.forEach(function(h,i){if(b===h&&g.length!==i+1){b=a.placement.split("-")[0],e=c(b);var j=d(a.offsets.popper),k=["right","bottom"].indexOf(b)!==-1;(k&&Math.floor(a.offsets.reference[b])>Math.floor(j[e])||!k&&Math.floor(a.offsets.reference[b])g[m]&&(a.offsets.popper[k]+=h[k]+n-g[m]);var o=h[k]+h[j]/2-n/2,p=o-d(a.offsets.popper)[k];return p=Math.max(Math.min(g[j]-n,p),0),e[k]=p,e[l]="",a.offsets.arrow=e,a.arrowElement=c,a},Object.assign||Object.defineProperty(Object,"assign",{enumerable:!1,configurable:!0,writable:!0,value:function(a){if(void 0===a||null===a)throw new TypeError("Cannot convert first argument to object");for(var b=Object(a),c=1;c=0;){if(this.isStepPotentiallyVisible(this.getStepConfig(b)))return b;b--}return null},c.prototype.isLastStep=function(a){var b=this.getNextStepNumber(a);return null===b},c.prototype.isFirstStep=function(a){var b=this.getPreviousStepNumber(a);return null===b},c.prototype.isStepPotentiallyVisible=function(a){return!!a&&(!!this.isStepActuallyVisible(a)||(!("undefined"==typeof a.orphan||!a.orphan)||!("undefined"==typeof a.delay||!a.delay)))},c.prototype.isStepActuallyVisible=function(a){if(!a)return!1;var b=this.getStepTarget(a);return!!(b&&b.length&&b.is(":visible"))&&!!b.length},c.prototype.next=function(){return this.gotoStep(this.getNextStepNumber())},c.prototype.previous=function(){return this.gotoStep(this.getPreviousStepNumber(),-1)},c.prototype.gotoStep=function(a,b){if(a<0)return this.endTour();var c=this.getStepConfig(a);return null===c?this.endTour():this._gotoStep(c,b)},c.prototype._gotoStep=function(a,b){if(!a)return this.endTour();if("undefined"!=typeof a.delay&&a.delay&&!a.delayed)return a.delayed=!0,window.setTimeout(this._gotoStep.bind(this),a.delay,a,b),this;if(!a.orphan&&!this.isStepActuallyVisible(a)){var c=b==-1?"getPreviousStepNumber":"getNextStepNumber";return this.gotoStep(this[c](a.stepNumber),b)}return this.hide(),this.fireEventHandlers("beforeRender",a),this.renderStep(a),this.fireEventHandlers("afterRender",a),this},c.prototype.getStepConfig=function(b){if(null===b||b<0||b>=this.steps.length)return null;var c=this.normalizeStepConfig(this.steps[b]);return c=a.extend(c,{stepNumber:b})},c.prototype.normalizeStepConfig=function(b){return"undefined"!=typeof b.reflex&&"undefined"==typeof b.moveAfterClick&&(b.moveAfterClick=b.reflex),"undefined"!=typeof b.element&&"undefined"==typeof b.target&&(b.target=b.element),"undefined"!=typeof b.content&&"undefined"==typeof b.body&&(b.body=b.content),b=a.extend({},this.stepDefaults,b),b=a.extend({},{attachTo:b.target,attachPoint:"after"},b)},c.prototype.getStepTarget=function(b){return b.target?a(b.target):null},c.prototype.fireEventHandlers=function(a,b){return"undefined"==typeof this.eventHandlers[a]?this:(this.eventHandlers[a].forEach(function(a){a.call(this,b)},this),this)},c.prototype.addEventHandler=function(a,b){return"undefined"==typeof this.eventHandlers[a]&&(this.eventHandlers[a]=[]),this.eventHandlers[a].push(b),this},c.prototype.processStepListeners=function(b){if(this.listeners.push({node:this.currentStepNode,args:["click",'[data-role="next"]',a.proxy(this.next,this)]},{node:this.currentStepNode,args:["click",'[data-role="previous"]',a.proxy(this.previous,this)]},{node:this.currentStepNode,args:["click",'[data-role="end"]',a.proxy(this.endTour,this)]},{node:a("body"),args:["keydown",a.proxy(this.handleKeyDown,this)]}),b.moveOnClick){var c=this.getStepTarget(b);this.listeners.push({node:c,args:["click",a.proxy(function(b){0===a(b.target).parents('[data-flexitour="container"]').length&&window.setTimeout(a.proxy(this.next,this),100)},this)]})}return this.listeners.forEach(function(a){a.node.on.apply(a.node,a.args)}),this},c.prototype.resetStepListeners=function(){return this.listeners&&this.listeners.forEach(function(a){a.node.off.apply(a.node,a.args)}),this.listeners=[],this},c.prototype.renderStep=function(b){this.currentStepConfig=b,this.setCurrentStepNumber(b.stepNumber);var c=a(this.getTemplateContent());return c.find('[data-placeholder="title"]').html(b.title),c.find('[data-placeholder="body"]').html(b.body),this.isFirstStep(b.stepNumber)?c.find('[data-role="previous"]').prop("disabled",!0):c.find('[data-role="previous"]').prop("disabled",!1),this.isLastStep(b.stepNumber)?c.find('[data-role="next"]').prop("disabled",!0):c.find('[data-role="next"]').prop("disabled",!1),c.find('[data-role="previous"]').attr("role","button"),c.find('[data-role="next"]').attr("role","button"),c.find('[data-role="end"]').attr("role","button"),b.template=c,this.addStepToPage(b),this.processStepListeners(b),this},c.prototype.getTemplateContent=function(){return a(this.templateContent).clone()},c.prototype.addStepToPage=function(c){var d=(c.template,a('').html(c.template).hide()),e=a("body, html").stop(!0,!0);if(this.isStepActuallyVisible(c)){var f=this.calculateZIndex(this.getStepTarget(c));f&&(c.zIndex=f+1),c.zIndex&&d.css("zIndex",c.zIndex+1),this.positionBackdrop(c),"append"===c.attachPoint?(a(c.attachTo).append(d),this.currentStepNode=d):this.currentStepNode=d.insertAfter(a(c.attachTo)),this.currentStepNode.css({top:0,left:0}),e.animate({scrollTop:this.calculateScrollTop(c)}).promise().then(a.proxy(function(){this.positionStep(c),this.revealStep(c)},this))}else c.orphan&&(c.isOrphan=!0,c.attachTo="body",c.attachPoint="append",this.positionBackdrop(c),d.addClass("orphan"),a(c.attachTo).append(d),this.currentStepNode=d,this.currentStepNode.offset(this.calculateStepPositionInPage()),this.currentStepPopper=new b(a("body"),this.currentStepNode[0],{placement:c.placement+"-start",arrowElement:'[data-role="arrow"]',modifiers:[]}),this.revealStep(c));return this},c.prototype.revealStep=function(b){return this.currentStepNode.fadeIn("",a.proxy(function(){this.announceStep(b),this.currentStepNode.focus(),window.setTimeout(a.proxy(function(){this.currentStepNode&&this.currentStepNode.focus()},this),100)},this)),this},c.prototype.announceStep=function(a){var b="tour-step-"+this.tourName+"-"+a.stepNumber;this.currentStepNode.attr("id",b);var c=this.currentStepNode.find('[data-placeholder="body"]').first();c.attr("id",b+"-body"),c.attr("role","document");var d=this.currentStepNode.find('[data-placeholder="title"]').first();d.attr("id",b+"-title"),d.attr("aria-labelledby",b+"-body"),this.currentStepNode.attr("role","dialog"),this.currentStepNode.attr("tabindex",0),this.currentStepNode.attr("aria-labelledby",b+"-title"),this.currentStepNode.attr("aria-describedby",b+"-body");var e=this.getStepTarget(a);return e&&(e.attr("tabindex")||e.attr("tabindex",0),e.data("original-describedby",e.attr("aria-describedby")).attr("aria-describedby",b+"-body")),this},c.prototype.handleKeyDown=function(b){var c="a[href], link[href], [draggable=true], [contenteditable=true], :input:enabled, [tabindex], button";switch(b.keyCode){case 27:this.endTour();break;case 9:(function(){if(this.currentStepConfig.hasBackdrop){var d=a(document.activeElement),e=this.getStepTarget(this.currentStepConfig),f=a(c),g=void 0;f.filter(function(a,b){if(d.is(b))return g=a,!1});var h=void 0,i=void 0,j=void 0;if(g){var k=1;b.shiftKey&&(k=-1),h=g;do h+=k,i=a(f[h]);while(i.length&&i.is(":disabled")||i.is(":hidden"));i.length?(j=i.closest(e).length,j=j||i.closest(this.currentStepNode).length):j=!1}j?i.focus():b.shiftKey?this.currentStepNode.find(c).last().focus():this.currentStepConfig.isOrphan?this.currentStepNode.focus():e.focus(),b.preventDefault()}}).call(this)}},c.prototype.startTour=function(a){return"undefined"==typeof a&&(a=this.getCurrentStepNumber()),this.fireEventHandlers("beforeStart",a),this.gotoStep(a),this.fireEventHandlers("afterStart",a),this},c.prototype.restartTour=function(){return this.startTour(0)},c.prototype.endTour=function(){if(this.fireEventHandlers("beforeEnd"),this.currentStepConfig){var a=this.getStepTarget(this.currentStepConfig);a&&(a.attr("tabindex")||a.attr("tabindex","-1"),a.focus())}return this.hide(!0),this.fireEventHandlers("afterEnd"),this},c.prototype.hide=function(b){if(this.fireEventHandlers("beforeHide"),this.currentStepNode&&this.currentStepNode.length&&(this.currentStepNode.hide(),this.currentStepPopper&&this.currentStepPopper.destroy()),this.currentStepConfig){var c=this.getStepTarget(this.currentStepConfig);c&&(c.data("original-labelledby")&&c.attr("aria-labelledby",c.data("original-labelledby")),c.data("original-describedby")&&c.attr("aria-describedby",c.data("original-describedby")),c.data("original-tabindex")&&c.attr("tabindex",c.data("tabindex"))),this.currentStepConfig=null}var d=0;return b&&(d=400),a('[data-flexitour="step-background"]').remove(),a('[data-flexitour="step-backdrop"]').removeAttr("data-flexitour"),a('[data-flexitour="backdrop"]').fadeOut(d,function(){a(this).remove()}),this.resetStepListeners(),this.fireEventHandlers("afterHide"),this.currentStepNode=null,this.currentStepPopper=null,this},c.prototype.show=function(){var a=this.getCurrentStepNumber();return this.gotoStep(a)},c.prototype.getStepContainer=function(){return a(this.currentStepNode)},c.prototype.calculateScrollTop=function(b){var c=a(window).scrollTop(),d=a(window).height(),e=this.getStepTarget(b);return c="top"===b.placement?e.offset().top-d/2:"bottom"===b.placement?e.offset().top+e.height()-d/2:e.height()<=.8*d?e.offset().top-(d-e.height())/2:e.offset().top-.2*d,c=Math.max(0,c),c=Math.min(a(document).height()-d,c),Math.ceil(c)},c.prototype.calculateStepPositionInPage=function(){var b=a(window).height(),c=this.currentStepNode.height(),d=a(window).scrollTop(),e=a(window).width(),f=this.currentStepNode.width(),g=a(window).scrollLeft();return{top:Math.ceil(d+(b-c)/2),left:Math.ceil(g+(e-f)/2)}},c.prototype.positionStep=function(c){var d=this.currentStepNode;if(!d||!d.length)return this;var e=void 0;switch(c.placement){case"left":e=["left","right","top","bottom"];break;case"right":e=["right","left","top","bottom"];break;case"top":e=["top","bottom","right","left"];break;case"bottom":e=["bottom","top","right","left"];break;default:e="flip"}var f=this.getStepTarget(c),g=a('[data-flexitour="step-background"]');return g.length&&(f=g),this.currentStepPopper=new b(f,d[0],{placement:c.placement+"-start",removeOnDestroy:!0,flipBehavior:e,arrowElement:'[data-role="arrow"]',modifiers:["shift","offset","preventOverflow","keepTogether",this.centerPopper,"arrow","flip","applyStyle"]}),this},c.prototype.positionBackdrop=function(b){if(b.backdrop){this.currentStepConfig.hasBackdrop=!0;var c=a('
');if(b.zIndex?"append"===b.attachPoint?a(b.attachTo).append(c):c.insertAfter(a(b.attachTo)):a("body").append(c),this.isStepActuallyVisible(b)){var d=a('
'),e=this.getStepTarget(b),f=10,g=e;f&&(g=a("body")),d.css({width:e.outerWidth()+f+f,height:e.outerHeight()+f+f,left:e.offset().left-f,top:e.offset().top-f,backgroundColor:this.calculateInherittedBackgroundColor(g)}),e.offset().left").hide();a("body").append(c);var d=c.css("backgroundColor");for(c.remove(),b=a(b);b.length&&b[0]!==document;){var e=b.css("backgroundColor");if(e!==d)return e;b=b.parent()}return null},c.prototype.calculatePosition=function(b){for(b=a(b);b.length&&b[0]!==document;){var c=b.css("position");if("static"!==c)return c;b=b.parent()}return null},c.prototype.centerPopper=function(a){if(!this.isModifierRequired(c.prototype.centerPopper,this.modifiers.keepTogether))return console.warn("WARNING: keepTogether modifier is required by centerPopper modifier in order to work, be sure to include it before arrow!"),a;var b=a.placement.split("-")[0],d=a.offsets.reference,e=["left","right"].indexOf(b)!==-1,f=e?"height":"width",g=e?"top":"left";return a.offsets.popper[g]+=Math.max(d[f]/2-a.offsets.popper[f]/2,0),a},"object"===("undefined"==typeof exports?"undefined":d(exports))&&(module.exports=c),c}); \ No newline at end of file diff --git a/admin/tool/usertours/amd/build/usertours.min.js b/admin/tool/usertours/amd/build/usertours.min.js new file mode 100644 index 00000000000..417008ad4c4 --- /dev/null +++ b/admin/tool/usertours/amd/build/usertours.min.js @@ -0,0 +1 @@ +define(["core/ajax","tool_usertours/tour","jquery","core/templates","core/str","core/log","core/notification"],function(a,b,c,d,e,f,g){var h={tourId:null,currentTour:null,context:null,init:function(a,b,d){h.tourId=a,h.context=d,"undefined"==typeof b&&(b=!0),b&&h.fetchTour(a),h.addResetLink(),c("body").on("click",'[data-action="tool_usertours/resetpagetour"]',function(a){a.preventDefault(),h.resetTourState(h.tourId)})},fetchTour:function(b){c.when(a.call([{methodname:"tool_usertours_fetch_and_start_tour",args:{tourid:b,context:h.context,pageurl:window.location.href}}])[0],d.render("tool_usertours/tourstep",{})).then(function(a,c){h.startBootstrapTour(b,c[0],a.tourconfig)}).fail(g.exception)},addResetLink:function(){e.get_string("resettouronpage","tool_usertours").done(function(a){c("footer, .logininfo").last().append('")})},startBootstrapTour:function(a,c,d){h.currentTour&&(d.onEnd=null,h.currentTour.endTour(),delete h.currentTour),d.eventHandlers={afterEnd:[h.markTourComplete],afterRender:[h.markStepShown]},d.tourName=d.name,delete d.name,d.template=c,d.steps=d.steps.map(function(a){return"undefined"!=typeof a.element&&(a.target=a.element,delete a.element),"undefined"!=typeof a.reflex&&(a.moveOnClick=!!a.reflex,delete a.reflex),"undefined"!=typeof a.content&&(a.body=a.content,delete a.content),a}),h.currentTour=new b(d),h.currentTour.startTour()},markStepShown:function(){var b=this.getStepConfig(this.getCurrentStepNumber());c.when(a.call([{methodname:"tool_usertours_step_shown",args:{tourid:h.tourId,context:h.context,pageurl:window.location.href,stepid:b.stepid,stepindex:this.getCurrentStepNumber()}}])[0]).fail(f.error)},markTourComplete:function(){var b=this.getStepConfig(this.getCurrentStepNumber());c.when(a.call([{methodname:"tool_usertours_complete_tour",args:{tourid:h.tourId,context:h.context,pageurl:window.location.href,stepid:b.stepid,stepindex:this.getCurrentStepNumber()}}])[0]).fail(f.error)},resetTourState:function(b){c.when(a.call([{methodname:"tool_usertours_reset_tour",args:{tourid:b,context:h.context,pageurl:window.location.href}}])[0]).then(function(a){a.startTour&&h.fetchTour(a.startTour)}).fail(g.exception)}};return{init:h.init,resetTourState:h.resetTourState}}); \ No newline at end of file diff --git a/admin/tool/usertours/amd/readme_moodle.txt b/admin/tool/usertours/amd/readme_moodle.txt new file mode 100644 index 00000000000..d2db94e8cef --- /dev/null +++ b/admin/tool/usertours/amd/readme_moodle.txt @@ -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 index 00000000000..ba520199ef3 --- /dev/null +++ b/admin/tool/usertours/amd/src/managesteps.js @@ -0,0 +1,64 @@ +/** + * Step management code. + * + * @module tool_usertours/managesteps + * @class managesteps + * @package tool_usertours + * @copyright 2016 Andrew Nicols + */ +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 index 00000000000..f5c601a54fa --- /dev/null +++ b/admin/tool/usertours/amd/src/managetours.js @@ -0,0 +1,64 @@ +/** + * Tour management code. + * + * @module tool_usertours/managetours + * @class managetours + * @package tool_usertours + * @copyright 2016 Andrew Nicols + */ +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 index 00000000000..16119abdb91 --- /dev/null +++ b/admin/tool/usertours/amd/src/popper.js @@ -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 index 00000000000..46a0e199110 --- /dev/null +++ b/admin/tool/usertours/amd/src/tour.js @@ -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 = $('').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 = $('
'); + + 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 = $('
'); + + 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 + //
+ 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 = $('
').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 index 00000000000..e0d877d52c3 --- /dev/null +++ b/admin/tool/usertours/amd/src/usertours.js @@ -0,0 +1,238 @@ +/** + * User tour control library. + * + * @module tool_usertours/usertours + * @class usertours + * @package tool_usertours + * @copyright 2016 Andrew Nicols + */ +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( + '
' + + '' + + s + + '' + + '
' + ); + }); + }, + + /** + * 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 index 00000000000..3caac6f0f71 --- /dev/null +++ b/admin/tool/usertours/classes/configuration.php @@ -0,0 +1,118 @@ +. + +/** + * Step configuration detail class. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..3537b61f7b7 --- /dev/null +++ b/admin/tool/usertours/classes/event/step_shown.php @@ -0,0 +1,146 @@ +. + +/** + * The tool_usertours step_shown event. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..945d1e4eeb9 --- /dev/null +++ b/admin/tool/usertours/classes/event/tour_ended.php @@ -0,0 +1,145 @@ +. + +/** + * The tool_usertours tour_ended event. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..bd9e60d02c2 --- /dev/null +++ b/admin/tool/usertours/classes/event/tour_reset.php @@ -0,0 +1,132 @@ +. + +/** + * The tool_usertours tour_reset event. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..915cb586de3 --- /dev/null +++ b/admin/tool/usertours/classes/event/tour_started.php @@ -0,0 +1,131 @@ +. + +/** + * The tool_usertours tour_started event. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..cac492d54ed --- /dev/null +++ b/admin/tool/usertours/classes/external/tour.php @@ -0,0 +1,330 @@ +. + +/** + * Web Service functions for steps. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..d27db08b97d --- /dev/null +++ b/admin/tool/usertours/classes/helper.php @@ -0,0 +1,523 @@ +. + +/** + * Tour helper. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..ff1de4442a9 --- /dev/null +++ b/admin/tool/usertours/classes/local/filter/base.php @@ -0,0 +1,135 @@ +. + +/** + * Filter base. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..96bc6ea29dc --- /dev/null +++ b/admin/tool/usertours/classes/local/filter/role.php @@ -0,0 +1,99 @@ +. + +/** + * Theme filter. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..786684c9c13 --- /dev/null +++ b/admin/tool/usertours/classes/local/filter/theme.php @@ -0,0 +1,102 @@ +. + +/** + * Theme filter. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..5610efcb9cb --- /dev/null +++ b/admin/tool/usertours/classes/local/forms/editstep.php @@ -0,0 +1,96 @@ +. + +/** + * Form for editing steps. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..3bb71aeb013 --- /dev/null +++ b/admin/tool/usertours/classes/local/forms/edittour.php @@ -0,0 +1,95 @@ +. + +/** + * Form for editing tours. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..ccb82dbc2b2 --- /dev/null +++ b/admin/tool/usertours/classes/local/forms/importtour.php @@ -0,0 +1,56 @@ +. + +/** + * Form for editing tours. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..2afafb82c63 --- /dev/null +++ b/admin/tool/usertours/classes/local/table/step_list.php @@ -0,0 +1,143 @@ +. + +/** + * Table to show the list of steps in a tour. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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(' ', $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 index 00000000000..10de685c127 --- /dev/null +++ b/admin/tool/usertours/classes/local/table/tour_list.php @@ -0,0 +1,150 @@ +. + +/** + * Table to show the list of tours. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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(' ', $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 index 00000000000..865173a3b58 --- /dev/null +++ b/admin/tool/usertours/classes/local/target/base.php @@ -0,0 +1,119 @@ +. + +/** + * Target base. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..878b045c1be --- /dev/null +++ b/admin/tool/usertours/classes/local/target/block.php @@ -0,0 +1,115 @@ +. + +/** + * Block target. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..410305cf807 --- /dev/null +++ b/admin/tool/usertours/classes/local/target/selector.php @@ -0,0 +1,107 @@ +. + +/** + * Selector target. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..2af061c4311 --- /dev/null +++ b/admin/tool/usertours/classes/local/target/unattached.php @@ -0,0 +1,100 @@ +. + +/** + * A step designed to be orphaned. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..f0c40b2843c --- /dev/null +++ b/admin/tool/usertours/classes/manager.php @@ -0,0 +1,769 @@ +. + +/** + * Tour manager. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 = <<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 index 00000000000..27c1ae37901 --- /dev/null +++ b/admin/tool/usertours/classes/output/renderer.php @@ -0,0 +1,36 @@ +. + +/** + * Renderer. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..3da0d09c8f8 --- /dev/null +++ b/admin/tool/usertours/classes/output/step.php @@ -0,0 +1,121 @@ +. + +/** + * Tour Step Renderable. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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", "
\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 index 00000000000..dda00f4b962 --- /dev/null +++ b/admin/tool/usertours/classes/output/tour.php @@ -0,0 +1,71 @@ +. + +/** + * Tour renderable. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..7b1ca27b866 --- /dev/null +++ b/admin/tool/usertours/classes/step.php @@ -0,0 +1,629 @@ +. + +/** + * Step class. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..f65c2caa0e2 --- /dev/null +++ b/admin/tool/usertours/classes/target.php @@ -0,0 +1,128 @@ +. + +/** + * Target class. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..b628744d9aa --- /dev/null +++ b/admin/tool/usertours/classes/tour.php @@ -0,0 +1,761 @@ +. + +/** + * Tour class. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 + * @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 index 00000000000..cb7bc983218 --- /dev/null +++ b/admin/tool/usertours/configure.php @@ -0,0 +1,35 @@ +. + +/** + * Configuration page. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 index 00000000000..1e8e6b927c9 --- /dev/null +++ b/admin/tool/usertours/db/access.php @@ -0,0 +1,36 @@ +. + +/** + * Plugin capabilities. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 index 00000000000..3b6ab9dae86 --- /dev/null +++ b/admin/tool/usertours/db/install.php @@ -0,0 +1,74 @@ +. + +/** + * Install code for tours. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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 index 00000000000..3402bd88001 --- /dev/null +++ b/admin/tool/usertours/db/install.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+
+
diff --git a/admin/tool/usertours/db/services.php b/admin/tool/usertours/db/services.php new file mode 100644 index 00000000000..38e65eaa5a5 --- /dev/null +++ b/admin/tool/usertours/db/services.php @@ -0,0 +1,63 @@ +. + +/** + * 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 index 00000000000..9d3fae6efa6 --- /dev/null +++ b/admin/tool/usertours/lang/en/tool_usertours.php @@ -0,0 +1,162 @@ +. + +/** + * Strings for tool_usertours. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @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: +
+
Plain text
+
A plain text description
+
Moodle MultiLang
+
A string which makes use of the Moodle MultiLang format
+
Moodle Translated string
+
A value found in a standard Moodle language file in the format identifier,component
+
'; +$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. +
+
Block
+
Display the step next to the first matching block of the type on the page
+
Selector
+
CSS Selectors are a powerful way which allow you to select different parts of the page based on metadata built into the page.
+
Display in middle of the page
+
Instead of associating the step with a specific part of the page you can have it displayed in the middle of the page.
+
'; +$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: +
+
Plain text
+
A plain text description
+
Moodle MultiLang
+
A string which makes use of the Moodle MultiLang format
+
Moodle Translated string
+
A value found in a standard Moodle language file in the format identifier,component
+
'; +$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'] = 'CSS Selectors'; +$string['target_selector_targetvalue_help'] = 'You can use a "CSS Selector" to target almost any element on the page. + +CSS Selectors are very powerful and you can easily find parts of the page by building up the selector gradually. + +Mozilla provide some [very good +documentation](https://developer.mozilla.org/en/docs/Web/Guide/CSS/Getting_started/Selectors) +for selectors which may help you to build your selectors. + +You will also find your browser\'s developer tools to be extremely useful in creating these selectors: + +* [Google Chrome](https://developer.chrome.com/devtools#dom-and-styles) +* [Mozilla Firefox](https://developer.mozilla.org/en-US/docs/Tools/DOM_Property_Viewer) +* [Microsoft Edge](https://developer.microsoft.com/en-us/microsoft-edge/platform/documentation/f12-devtools-guide/) +* [Apple Safari](https://developer.apple.com/library/iad/documentation/AppleApplications/Conceptual/Safari_Developer_Guide/ResourcesandtheDOM/ResourcesandtheDOM.html#//apple_ref/doc/uid/TP40007874-CH3-SW1) +'; +$string['viewtour_info'] = 'This is the \'{$a->tourname}\' tour. It applies to the path \'{$a->path}\'.'; +$string['viewtour_edit'] = 'You can edit the tour defaults and force the tour to be displayed to all users again.'; +$string['tour_resetforall'] = 'The state of the tour has been reset. It will be displayed to all users again.'; diff --git a/admin/tool/usertours/lib.php b/admin/tool/usertours/lib.php new file mode 100644 index 00000000000..476dd093847 --- /dev/null +++ b/admin/tool/usertours/lib.php @@ -0,0 +1,69 @@ +. + +/** + * Tour. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +use tool_usertours\helper; + +/** + * Manage inplace editable saves. + * + * @param string $itemtype The type of item. + * @param int $itemid The ID of the item. + * @param mixed $newvalue The new value + * @return string + */ +function tool_usertours_inplace_editable($itemtype, $itemid, $newvalue) { + $context = \context_system::instance(); + external_api::validate_context($context); + require_capability('tool/usertours:managetours', $context); + + if ($itemtype === 'tourname') { + $tour = helper::get_tour($itemid); + $tour->set_name($newvalue)->persist(); + + return helper::render_tourname_inplace_editable($tour); + } else if ($itemtype === 'tourdescription') { + $tour = helper::get_tour($itemid); + $tour->set_description($newvalue)->persist(); + + return helper::render_tourdescription_inplace_editable($tour); + } else if ($itemtype === 'tourenabled') { + $tour = helper::get_tour($itemid); + $tour->set_enabled(!!$newvalue)->persist(); + return helper::render_tourenabled_inplace_editable($tour); + } else if ($itemtype === 'stepname') { + $step = helper::get_step($itemid); + $step->set_title($newvalue)->persist(); + + return helper::render_stepname_inplace_editable($step); + } +} + +/** + * Extend the user navigation to bootstrap tours. + */ +function tool_usertours_extend_navigation_user() { + \tool_usertours\helper::bootstrap(); +} diff --git a/admin/tool/usertours/pix/b/tour-import.png b/admin/tool/usertours/pix/b/tour-import.png new file mode 100644 index 0000000000000000000000000000000000000000..b0fdd46a504d8c3badda55bd8c730b5d7c9e15fb GIT binary patch literal 1734 zcmV;%208hOP)hX-z)9QnFvfS&Vey?7YUEZB6HgSjmxccg=M~5fpj$-1;NGX`Tz~k1(^XFgvXY2xu zjN}fRJ!|g%d+xcnhm>N~ie>!Wk3Z}H;Evb@0MwLy_ukjrfByrKRul>a_1zEO#qLm} z29!!;NE=2+M=>aDE`mroh-7Sn0uXJ&4j|fuQov4}5)dG@5vTM!ApKFd3y|ul6Qw|a z)vF6=G#aqs%db8=s8}p+i~opBCJW`M&exuYK$KvGTW-B^FTfoPerNtwi|*-5fJUQ+ zN~MBsSh;e=R)75Y$MHuVeiW4FVSsQJfQj(t+i#qB%PrTxH*enJ4`WS%YOOj`=0grS z3@L^P0QS_*0D2c->#etj4W)7szyI+&1hrZX?RE=kLN1q=o?>$^;3Pn;)`0K(-RnfN zg|vd10kFFOg~DoU5v*3qsU`2=0Edo^(@)%4fSEIAW9roDT@Nj*3oBAfoLR3s3NSi4 zY(2^4^TR15@16n@m8MhQL?Hq6;K1WyEfGW6d6Q_xk{%ZubF|(!!p`09c9hFy zEL*l5K>vHXeNE08lO@FrComiTPt-YpB8hkI11M6AHEUN}`Uyf1_sFUN}ET7G0YBWEfNd9Win^h$ z8BN)zn}a9H2w3)4&SDT1w`K{O`O}#-X}f>~6l35qUk!h<1QO)I-Cb z0=<)TwR<#qaf2lVd^7qM;-Rli2RQEY4tBSY*J~CBSWkTjkv7(p>X6beo4nnl$m^j+ z%dIB>mw)~BXYzgDgXsduir3W^kROrJFP~JAGiJ+L=l=%n91_=oo03uGz?vFqHC`zRgG^E-~=PAE0bKnQ4H|h}RZP=sc zBLfw{Zug#xoF(!?GKX0+W?|=@b^#GVuI_?h;+nqMYC^`8whsjlh2k1Xg%9S z1>iXL^qy=#G&F?Kkx7^{doG&ICUoB+&&6U9OaS?eQn`p$yUFmpGawkC0C5z#o^w8* z%YiBUEqZZ+be}Tn^*U;`Dr(Itk2gnmpMUm+-zOk|xeK`2-CpoA2mrg?oyd#5 zHE}(+T5WjBgQS2EuT(22S4$ucNGUaM!Ig{d7@rv$NeGb3Z`A&L_aYy zfBxdPZ@%@$C0~5;`Eksw&arkGZx~5O@aAWExaCp cj130=0(tRj$5t`uHvj+t07*qoM6N<$f+ez1W&i*H literal 0 HcmV?d00001 diff --git a/admin/tool/usertours/pix/b/tour-new.png b/admin/tool/usertours/pix/b/tour-new.png new file mode 100644 index 0000000000000000000000000000000000000000..e1af0df398a744e185baf27c3f30f0ac49d80ae2 GIT binary patch literal 2485 zcmV;m2}<^fP)EC7#pdKp}#$&j@1_`$vZsZHh({XLZC91O>ndn3Sa12+xQCeK|vK z`~|GMc!y*7S{Jo8!L<--h^CY%00{vL1%V9bhj{6c>l1PA-afUb99&y!453o*|TTeEXy9IX?jOQG=>Oq zt^hEziV#A@=Gl8+%>c2h^~yOb^XBtJymZ=&uGQ=(&HBJK>O)s=22XI&EZNPPLpcDw!H-o1ONbB>j4 z7O=j7^_TWAvl7iv73DQ{NwD4x7NLQ z;nO6PDV8dO0yF-IVEY!Js`qc-zMW1?Ob}(TIV%!2zxZKeW(d=fA=m&Rjkt&b9;`sf zfDs}Eup&T!8UUhou3f#PQ&ZEILC=D70GI@Nc?nGKTY#BWU%o6$e*OBz%1f_o*SI?< zilUlDWV4vf>%D)Yhtq$N>Bwwk9+kPsBC5-=dkBL#XP6a;dH^gqj1;pvE;&M))Hu>D z72H`2Ks=NMR?p3{!GGH9gGZGeiQ@RL+%!z$<($6%2itrz8Jc$OwFWkgMwfZ)lXO%) z5zgPI%C?BGz6bzB#I?^C!|2LN7a!Hk#zKkrn)$9Y0PX9gh$C((514@Hjw1J12}fPI z`3PV@{|zxR4zlj$u(h?~34n`1Jn8*&B3i}o1`$@mT0}Fs)_T6FK{>;S=!tJcJP>!7 z0FLBF=1Rm$12&&mO8J^@Aa}CcP*;wz$bTFj+BsozVP8IP^=VQ^0|;5$$_=5wAVmk7W6h+)|b@=CVBK#>WGd3jmx~_ERy>%qu}u z;UMfq>{^eXrv0`75pfjdTjB)37ovPd+#qG$VM+7|Q3f|RuTVad<3cHO;!b?j*xl-f zn@n*LfF{6`$TwY|07@B^7?d)!DjdkYXb@tDTRaf+llkw6FK`EO|>e?Hm=IVxqeU04_0kT>>un{vUC{y*vTW8$KuE$^>+e zDxJ(i6r7%QI5v@wvq5aIkR!kX8*+yO&cAOUM~t$Ls3xkaIvOudz`5c@hA##YQ}i+$ zgb8{)eosuvJ+_oB7UT_J1z3~su_U(SM%Ep2k3Ds-#KBkx*%U|LI$WB7XG}al1Yde6 zqQ^{An#!|srYT~5GP#f@0&+*paQ3;7`A%43Epvmq(jJPP9Cwe`WqV`nF+T2lGCwNrPW4{R9hwHkLU@Of+{(D+Y{8Mb#R^NAgXO9a^&Rzt20|{xxK?9r zYyFZT_{3nV|L>WQ9;9Y|E@LB`Ne5|=s)}MIyuunYOaO!PoJhT~er~y3QtMmBruTCE zphcq}mhB#J5rA6jvwe;0x5Ovk`$F1FyOZ%z%?e6%YW5;GBKFjkC>3Na#8l>kC=bRu z^46j7Kk@c;X2IYg5_W&=x483~wN^6TBe)!?}gV7b&@kR}G7}xy%e+xE0G$tu36cL`crA`^$afsxomcD?bsY!i|WX$O8`Kh(O`AbA-9$nBKkb$>ZNO^Xfmu@Y=oC@Dc$3H3xqC z?KfY2^_9Nw8-P#FhTnH0EYi19ph#1i@{U-`+zJN_B2s3epqHk)_{>;GZ{PJe{*8RA znZNV*2=&D$K>&DocrXlu!{P9>1-Qigg&-zsey2pMv(w7oiQM1}U?fLChl$inpF57b zj{v4*{H&;RUPuh08_qx0n-aaz`9+`+ml5 z|L5mFA$s``e5vWHcLqogm}7;F%-y*KPzfmL)$FA&jCJA7+uMJ(Zx%Xm!!Q_517i}5 zLA-Yv?^(wvhKV(Nf)kH+*B2WY{a=LuMz&lLcA-}6Nh)~Z9KvRfcC-OZj7)7Vc_ z!4p6TOano{bRe(*SRepcnTtD(R)-1a)nkZha`s59X_}!t-nnyU!Wk|@5Cmig!R8i1 zPoF+Di^U@K0f~B{JOOkwboA&^bNck@fq;)6KPH_ZXg~=7r_uxgIt!dUdD3|A^)N&W zO9M_oe&G2m5N81?p=cloz-s}5fc&C`rU5tyf?lv21R_5V4f-5k^#XMOAS4wy0m>F7 zfOH(kHH|v_xp)F34eAtAO0r94Wy@{>DB?U7EHy+W`1=X5+64&s4TSg{TmT9X z4&4luCn5bedeP+l#j`-+)g}l4gdm_K_K2M%fTsWtKqX482(jvuDOhcVq6FBk>(m?3 zfq-lV0TcyDDI8_EP-@SfJzJ${3{D6^ziIvt9z?^WMKwr08-Pw;=*i8SH;umUJ0QWuV26Zhzh_`u3o)* zsIKcuMq&2-`}g6?moK4d8Yctjgxa^r7N}-QrGBa7xf|6k?WomK)%*8K zJEg7CdUebR5CH!5t+FWs%+l#)Ak=;TWp4Qa=4Npq%%uSC00000NkvXXu0mjfisPd{ literal 0 HcmV?d00001 diff --git a/admin/tool/usertours/pix/b/tour-shared.png b/admin/tool/usertours/pix/b/tour-shared.png new file mode 100644 index 0000000000000000000000000000000000000000..84dd2fc32e8c29eea53277b1d7491eeaf2b852b0 GIT binary patch literal 3263 zcmV;w3_$aVP)qK^3!0{i@h@ny8Ca#m2L>dw1{53IEpPXYreEX3jan z395()$`}Pq(!PstS}5v1 zUAA<7ZT*I&p*0pGhT{<{9FKYpzWwfx%|HL#`Z%z4JeQ@LDi!MFae~p1VMGVT<0U7^ zMyW6$LN;v$Flz5zzI;LT;tO_#ZGZm3+y(QAqL}e2mlON*Ppw<_Tz13U&tAK~n^Vv} zW-=JhrR-A=wXceZ1s>+kISJNq#*gh&eHh=8h**4FO%f40xQa>KgrH?XdZ zHxV9#71gdhmw610sQ}KoZGRs|=FFMH$jAtXi(@$Nj2);d&BoBb1;}&9NSaeL9b**+ zmPSzkkYyR^U#ysiZsW`jg+C5QZt2QKGb`2UbjY%d^?C_F9sqPE830^)=z_dwtC0%lf? z{%#Yh9|to+h^*6muLM}!{;aJwAhO1}DNbgEj|(58rH>FfLi7#D8Q3A9;4;KUh%-UL zDK-vZOU$O2j!;g?nSL5nLFaR^^ zB7ocd+0_xi5Q&9CDaa5>3$>uE=LqB#xG#ttgWZD6K@J5vg0uy5O^gO23DFY7CnQ-? zs{xgQA5Dg;0)R!pzyh_g0OA0+zP@JEa{T2le}N6{<}ID}6`j^y;lsk`h3AM1_=W=n zP(+#_2VfphKz4+H+r9$`g7dsUjFAeDrC~b)f?-4;QlE$*0Gywnb99jK>C>m3B`Gv~ zT4+t;=Y?-}fXjgu*b-!n#VcTdFaeukwglE-sexsqC25dD$!aZUWj6!iva0@=Os(%VRU3}%K8CckR2Z-|keEj&4^V1H$|NZY_ zi=lFkx-+~xD!g5&fx~0*4sii&4$=V}NREXGF~2tGFCp9&>J!5d2_uMObUhKlYVCl> z&irj-DmZbV9@T`psCs16z|m+#%oki;U9pST70o22kys%u)F9CV4myfsjK~D62{@2| z#VPaoI36G77m!~=xP`n+2z^`}(4h}dD*=__pId^Z3ySx;M*|c=p1pX5cuF3YIF}-l z6*A0(mIW+!5SHb~A>dp>mY*}cUt}NNW&efEe}cqYeAXpqJrOd1S_!DJ1h)-PuYllv zLS4M~SajP{;LWQezI^!tRC$ZaD_p5wp&`*m7{>@puoMvknxt7qw||%YLDK&atq1Xr z7_QLlMvmyQVc&bei)nD;5LJA##|Zd@m>WPD{rR-T$;k9C!xUp4~OB&a&Pdg|g;$zuTE?Cgx!=ZBR9 zRQZnAKI+CqBE%UYDT@W0ct?z<1d9j@gf)<1F_6e1Y2N4h`Y=P5|KgCL2su^uPQcS6u=BRVJCCoP z0`hzUf(zSwfQ2O&7Z=pwPk;Io(F`m@$bm?-pA9(T`tC53Bf3=CHvvx+U5)<66-|;t zIgcSv>;RPvfN$Tv@&4^;Sy){}1Zb2pGmtiBTF7{b+V_A$&yGN+-2}j?qiljIFCiOO zr-IFSW=&FNdd$qs%=`pnX68+_KVi%aqKWmGnHdFR=B>;h zz0J>xM^`Iu)6>wB<@+eLeLws1yGucX&YnGc^4LIQXC#1w*OdTMtTd*#I&mB(gM=i~ zc8Vfs%O$`&IB6KY6R1Lr1eTSRLDz~Ro>^HG%e5W>dNvq30}k5u&LW>PpiuK`X{s?- zAHgr)uD6c>feV%+CWy^Yj~vSg;0vo(0sKA&W0U|9nCwgs>Ka>( zLlY+mgzqP~ocQ`-$h40DeG@_t%tn^sr$c^rCSEHAB?UQ9+t32Z6knH1z@c$vWhK(~ zn7V{UjT$8)K=wV#ouE(xg3zqzWcl#nhhM>={bykL${A2nT0lvNC(JzqJcfhrT}!wL z^0l@7iQiMB!O@feYZ#?vVadzRpah&g{Q>OVeUeUpHm*AWUwrXB6y|0@rtccg;&n*5 zz7;({+P3f18zD6r;zn4In+YEBf`bRn!3Xbu4$J5ChtWgJVb|f$VaK*(Flq8|?BM+{ zSjV8o4F7f_fM45N29--1?bjs}N!YRdD17@(CER(rO#^ zeIrbrHi{Z%WBqT<)OB42Ap&^?u-^pb4bn?6n0PD>TQ(n|)8t3*meWe*0CEun+j) zc^)}~$LmJ_=+m=}vIoPEvA8xy1 zJaYOt!n=VqtZCkI*%)~Cog+|P+X6rQ@B^e0O~eW{GG@#e8TyvUkdzRt?ICdIb#oyz zGn2okwo@iggcsi02E&F8BQk{jLf-?l(2qce!!Atwcn{pVglf2|s*0ZUkdh{$XZ6Bi zkV53(sSuTpMtb+|&Hj=7%T@uobL++A0(vqfC&5}1l-VIZPz9(0TU1$N9CouKpH^9< z!-5c}$0jBQq3tOe89R2Y?2C>20F9KClt5lyo_dA_8%lTiJ$(s8AVYtdm`M{R!a4%< z&LXEbOF#vGeTneoj2k!3dI_`}1$eB*d|uHARi7DwE7;&)b1TfdI(#31j;W}$H1mD? z_KmRIg^Qy>z+WZ8k{3Pr2pb~WRiI-q7f`~pZbO{NC7}Pl{xf0LSy4p$*Ox%_B(V)C zg}(p+JdP5i5UeT*tSr${phQ5Fj7Lox2Q9qE*6J!CWIn=d>#PC+=GYn=M1IcQnBS-5 zxUNfM&4y`|yFTIfA~V$Xu5`sCY#;C5y}RG?<;&CB`lIo9TyjqDyoDyaapT4m%6SNp zP%oA(TQ&oeunsR)2>&2KpFc-4XYJaxpGQgne-9H;0KW002ovPDHLkV1fyG9OnQ4 literal 0 HcmV?d00001 diff --git a/admin/tool/usertours/pix/i/reload.png b/admin/tool/usertours/pix/i/reload.png new file mode 100644 index 0000000000000000000000000000000000000000..beb8d71efe872605a1929c16be60d42595e1830f GIT binary patch literal 257 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFP2=EDU1=2G~0h|6Tb_D9-FA4Gs zW)M&?a0p0f=%2rS|M~mx`B$`U0!kHnx;TbZF!~t_q?9$v4uYtGBf-r zV3pWssIRc2{z9<)CoAF0yW{R;J7(zLR%c~xS9S2-&-;h5Wv?6m%-(bIK+73CUHx3v IIVCg!0N0UU761SM literal 0 HcmV?d00001 diff --git a/admin/tool/usertours/pix/i/reload.svg b/admin/tool/usertours/pix/i/reload.svg new file mode 100644 index 00000000000..2a3c4bb033f --- /dev/null +++ b/admin/tool/usertours/pix/i/reload.svg @@ -0,0 +1,3 @@ + +]> \ No newline at end of file diff --git a/admin/tool/usertours/pix/i/sprite-green.png b/admin/tool/usertours/pix/i/sprite-green.png new file mode 100644 index 0000000000000000000000000000000000000000..89fc8f2343c554babe0753c42c0d59ec3059f15a GIT binary patch literal 5405 zcmbW4=R2HTw8ozq6CTEl5iR;?A&E}(GBbKjloTyQ_cDSYL>pa1gapw`5D8JD2hl}K z5Jd02_g;_pJ%7PDU-q^3UTfWJ?=Qdm+OO_wt02f2$N&I9sHrOI0ssVZ6_NoA>U)f=rRdmFKcWu7N6l2?_Qihn8km%^UeoOWUdu~V%|J4JX))~2y^MfU8} zX>cA`ZWO>^G+GR-Dsv7TQ zD`sIJ{&dpMCnvi0csf4jQJndtmXGrH{oa8=Eav0~d$Sh#UjBK$0wVR#%af1m)DEAs zM+}ofGW%K7<`6KcK!;CmS-M}kVja}4g{S#M+&kzc2DCK-R`YKMZ1J5aB@(Wrqo@SMFo(oX8#hsZzGdEa?4z*RWw_n z0M=IJ@9%GN<{3}aNP4=`z;5g4k8@7u$W3%*)CBW@$VyR>VN{Q+py3`oEpi zg6m%~%YxC$cV_x6NeFSOx_`ZXNKc*lUJ5=F+1y!dF_#j=4-v!?1fEHOZ9<*AiW;F! zr6thpre;Fo$G26ZMqjea-p|~6uzBo{&3S(LyAymKdn?zXWU|C8fX`(%Vi}EYVOHjbb9LXpe+^QnO?%xpZl4KuK%2BzI-FASE|5T;_3Km`ZT-cox3h)+~zT9ioNBqsI|6$~?S>dYgeufO?SamcN@s&n!K7>ksN$dr*1 z1jE{K@w_nL6T)Id5UBKTn?fjdmDJTn{=S%nXgl2za^xZgC}yL zCB1*vXA=gugT6Ov3JU!A+Qb5IvPG-*OiiW#i!KfhjzV|R;#wrM*RR4@Ofuw^5a9e! z+}SfCJ#y#q!8T8DK$*UgJ(WY|pJmD0W^SoBV85QF{K^~d*Ir0far=x;kBW$*rK7WZ z`O>fojHQHGa+)HbRW=7gg#L|QMa%P78l^;>su?)f_wVCaMS%K7U$Gk0h(zZ_Sug6!dEhYu@jwj z)SREBS$SyVlj#Q;%!?$QA+krb1Lt1&8+};0bT(M3-I40&W!9sz!ViH)oiCuV6l-zK z>5iA6+jKc6MCSn5zbaoaf(U*hqO^6#z!IOlb})Pqh~_?|t=j}jQ= ztr(AGrBGn2WL;FbI_&i4H$ZMRFpB%e$Oxcjr6 z62;qrY8YDhx5r_qI7Ep;Il>DS7g7aw(S zM;*?!F3Big?;uuIwxBe&S(`R+nndjwtBX6bbHRa11Qd=E9dsWCvQb0+-`jS=t5>h+ z%)8uVUzzquiix?~dU#aci#(lw_TU2Pzg5XP*hAuT{82}j+S=Nh0cZd6<%z8;vQ;9~ zKA6sbczD=`^TtFJP$DqrNJ$JH&WsHjkMZu{!A+dv|nk%KH83_pqzGP|SB+511l#dVRX zaJC&n{+*j~FGpU%eYd*f%ZPe6D;UX{F0i5YPnz$VA&9BwfdcN=320!~oAm!;giC`rVv%w=Lhh;^K$N`OIU1UOs+_##$-)4L{UcGgt>o2x5`& zYNq;=ad4!>PI#{JkwgyOoSan>;INg~S`8ga?iCXX>71hvKqK7M#4dfmUQU2=r_=e0 zzJfwaEI;5*YTbbAR2C_EsMqsa#Jmd~@JdFOsI7xysJ9*WsG7JfCWi(xR7Brb!D{O# ze%r%HJ(X$mcY}aP2D~wqwW}WPp3>0zHcDa|2Y@vZ)Cb!K;mT{&)_Fu7C%X zJTT!EfVdYVj9q4z#%Z3P#)oK;bl0Oe---XD+dL7I?+Y>`y$lJj%zj+Lc-8^`Abe^%AYlZ_#>7xyin6#5v zxwK#auzR*5SCn6HD5(l+-=_SWLj$yMS5Y{q3<}=-K;-y=$co~=X{Y}MkA%+NEDiu1 z@>b80I}ijLz;oiv1_0%M_lc@(0>L1iJ;~~?w+(AG*iL@4WzsazZx z7ZGelw16JW?8AKd#u<;7(}I=3E@QCOT?jlp4__$Y8^5OKrwQKcb>@n^?PiZr5!49Bi zj+6lmrazHi6QSVS*bZ z)KUVc56s7&M4%i+JKiXhPRGrTU4fHDuCYl4oEEZ_35h_lsCE#kkxu`a3qpkhY(eZ8 zFat#4c%c9PZOxdi{%2~2kJVGWx?G6A^zy2sA(^Bj5$8t>bfkYJ5z7iHEn>F>{vBDc-xKKsEvl@vJvco4 zJv^MyKr>0{HwwcDiiOpc-#0QcQfHdF#w1P7^IA$B?=6*bVa)WBF_M`$Mv0m0U{fM9Q6VzJ%~N*2+`VtFF&q}X7B1ND zLyB*K1Qr7FDDuc?fT|6=2~W6`jFi}+>O5`@P8=B-VWikmWSLW<&x_=CW>p;-96T&> zpJ`~omekZZbai+C)`ECO(OJ1Ne-^nZecPec`33sWPj{pt6_{C9A*jUd`c4iF9a+~R zK9YBg$OcrBmwN%Tv$G3FM?-Ur_ld36S+L|{nh9G^JIa_x8iXLt_7GJAXwTy|P_kpj z7^ea1I!8ckb5c@L?$XiG-E_bVCnV5ku10u7pkuqAt!`{67q)1tdkTX~4&hs6TcLOo z8xr$G=1Lj!v)~PgErdrAbbSlp)AM<3Ac9djF9Ab<>^gLu1H(w!#gJvNX4u4=5SjWaT9LI$`TVcydjZC|7d7*s+|0(XW;O-5i1nm zt7idZut4@I>jwtn{=6lA83EvxEL+3Z*RI@fkPKsDl5%CoLJuErPc0a>&#Ya}p$HVg zh|$O_X>fD-$_%G!XpT#&BN)r6|c>udL{RoG#P-qxr}7^0P3&UVIsEZ z*n!*v$z{r#LTkj6+|mwn#()RD|1h8;jl+N?Kkt(2YR0VLzR<%=R~B-`D|a+?I~GZZ z2n0NCL340#R(3WeL7)-a%Ph)IX?x#fzcrYQ;IjA3m8Czwmw`Q=(t|~Ud*S3Y8(`+> z*tJBc3KT7+cN0I^+uKtIbMx|;VfCY}{#0DBXC~Z$HxrLPXg2OQmHc>*>dGzpnX@u~ zU){&;(*JqK5}p%ExaxN;rEeu9(n6OX%%gx!65=vcO_CNMA^12kwO;kA^E`(tDKT*( zdXF{ukc<)_R6R(IzS5v0Ko^@=h-8sjxvn!;UcNS|huVH$9u{FlrXxP(hK>7xnb%eK^&4WtNNt>5w04gXW z318+Ty&6Q??*?7Z9}jTB=A8$i}OZ0r~|lAknhlp&5Sg#5I*Pym7^-*1RK4e|3=Cxg;k|Gi$u;0 zHDECmba2T%P|Ad13;!*O4@N~r^^?!ils9INF^to8xQG@yogh(fk3@J>I6Q_o3=PFM z{&I4>G}`nQKJ;5T>M)PX0j$_vSg+{v9pQO zsGYn~=pbowJ`gd)wC9l#5-69RL6>b2B4*004oG z_wS%A$7@=4MhXD%elj;QaEutC=eYUsp66}%q)pe`G%+OXcbDFrp~wxRCHOw|#r!s2 zG(1ztg9Mukm}#3AO7k(PT1XEId1^_>@i`2%-D;XOn2_jZ^|3o63}!(|6`Id&X3v&u zll036SI545IfU(}mz)Yc$kO%hIY`ftml)w~FMRF3xyX`_H5yR(%6FLlF`;v`Spdy`I!2~qqqaz`|JZh-C-kRsjCGn74e!R96U z$}bV@;B%VUa!uHZ^UBupphIhGtHW``!=8eg_WF{!*(y5u(dYY+&4VZ{KGY-RqfDN3~ z6B?fHaHsQL{Dm*RGNq64!_5{)(sHI>>X^!zUBJE?ST;2EHLH7efQpVvZ@|pVzAdJu zZ*Ts-fwygc>Cd2$b*xSO%^;6X)D|$a-l1~G%nu}cPUB_5?H7hbe7C*hm|B&j=I%%5 z4+eRRrE6MK^`PlLvE6GgkPu^nY1u$zFp4~sUuDDi>C{t1k5@A|FCAmobxejU`bEcQZk zV(7B9#~^;OGRAlJ%oS#?`F_WaVb0R$l%74m+-J4iCu@W|#0fjy`&hmlaY6sS6G4&Gmv`AFPXF^DS4I}cp4991U3!?`3CB+K3Bns=5&F6#C>hrM@)BFeM z9e8UB{qRkscfE{nj%m(t++DtasXyt!6sOk~zzQm?_;Or=+4O7Ya!ErAw*8#8=Uvn_ zHLD#wOx9r0?Zo2Hqt>p#IV}fA(@**pL=1GmW|SLj@12n-u-KtGQ-ohGJ^8Lckp4XN zYDTm9l2GrSp}NsO88dpxt(KA{{NL>sx&4@Qx&1(5?GE?S8>uqAFN2}=z z0-Sd3Xwep>67I{uBQ>L;3rB9eNl;@}_cl9G)B@z=WpPU+80b$@4G;iM<1Zh8p3ffj z*)8ge(l|t=+wwmGlP@JA3ZXzmqky{#3*fGChyOm9d_H;R(F`y-BgtSu0S2d_o*@gq zxrNpIw}Cw#3I+hkF~ky%31$EQWHbQa;Q)Y!0stHW0RKPPR@{IbkTs)IpHcVY#}9{w z{fAGi5{ll$knBx5S+{d6+Uaq`1>*RRcrG+x zgxe=sKom~&X~Fm&AX?nlz_4W?@*C;1o2z1`z3`y7N4xqQQoW^ttDO5g$c%u?DG=5U z?ART#r?3937tVqN{zD_tPLCEH238)|CaiBP!Tt!N8+ z!)1ga#$NT-@^YxpFa>?jqmWJ12+q#V&oeRveI<+}p=LHstB}pFj;PTC5$4_;@}W41 zq2ovEY&`gCOTZp|>t{QYttY>1nJxN7#X-~}W5Cz^=1>~0>Zah8p4TfemT1z#9N8-* z{8p2!2I~OrDPL+`v_FfKK)KNe49ek{PpOBbSNbDX98y(yxkr50^__DGJziuk z)TQlofl%WB6}9hQtYv7p>+QK-moHNT#~TW_6B|8ktss8a=@;?`7Tb-`y0B*UP(3Tz z5V;g36&3a6Wt;Z8itYrDXY<{V1lr@eceMvy<2%%I*NiV_JNs#~Ge08a)fFC{2`|2* ztgFJpUi-O>E9m$Pd_i@mQkg|F3mdlfuydd4QVzg}-#Q(G$-7!bhc zefQ*R$mY*vuNCO)eR#HBOL`N<`yi{61N6^Z-IhXkzW6M=_cqk`?tR{;Qa%yP;L1u$ ze6O5j<+rzf_F&DRJq#r1-eRoK+tIa)N<8GKl<(XIhfkQiqLk_U*6eR}^qsWmE!F?5 zPF!tG=iUI3#5XN}ni8yznoEw>(GA-~CC8m+cXQ6AzbUpSn)ojr$nL3gGnH;+?Rq|SMRZicXp~18I+*CB-zl1V?5-W70JL34gYPTf z!na<1VD-mNr^qPx=H0~D`^C@hE8LKe3bu!AsDO!CzpA1(W+J5^U#ItFqk==Qy^>uZ zy@;8%lyX*?6BOMvfmz9#;oTNNJhlod4nANWl7Lk2s3rUF6M1N#2#QDv>XlYSVHqk6 z{u?AgV92V9Ny|`G_~iC1jG!J9&|OGGeKF|nz3Q|Od;2SZSe zz%a0nrKQ1RGqd3Y1e$6Kfr|5j2vB44e{HG}Lqaxeztld({N})6WMq=>CnvLnoR*DK z*a(MA>frcuVY?f~`7dO@eEm9@I5##nR@j3TnGq@GbB7?2$Z;BxVFun$T0ct&oD?1F zI-l&_YF+t$ZDMh9`S11h&?tEy;9qa)N<)2mdQOfm4|CKI0-XDm@1hBO`^`>MuoZJP zUk$$&Cx(pJtJc`uFgXzC!}5_9xajvESSD{7L|vjS)a;7T~Iuz_?phR;F(Lqho>6RMTF1jjgJF$H0DP+hOOFRGdfL z_k)EKTQN(2C@*%OIIt#L0mJ(b`(O$PA31~%@*E^A1LWFL()iYHj1tu?7(Z{5x;)wV zzNRLk_XqcCOJkG1RdGSjkIC^DGrh3o3wVz9L^p2YK_FyhM?)}VOu@BROEoD)gp7C> z-oGoZMr|rLYw9xf#;3}Vi5ByGCm(mY z0a6>AoM;7oq6LP-ZAA#}@bnyn*y?3D5u00ibg1qhcRuE&*qFqo6nLnw9}A31lWU(M zqt+H)#`hqIiZ{E+7ju|O-N5M{myyEWn|;k42+sO-kc*eEF`(t&wz+cCudD(*)N!>8 z?!}h)nQijC^&kjc_QW30;|pY4BlWZv*qN4oH5RAYv7Ssb0cH<_UTR)h5|e!(*hhjW z?>CP}IwQzjqt{|~^PK5LfL5n_(X27V_Lnz(Wz9ko==`B%*DIL=eDC7Amr*JC&qn_! znQ`<20chs?bpDuHOisD62lupx$buWW!yw=-g&0*15YWeRU2N#wAQLNFi=Z^P@*Oz0 zCV#KF>I}Rm7&{q;wfzfhRf-7EA(%U{u26s+K#J)#(92b(0>=8v5TLoL&fWV`Z@{He zI%w&tjICWuj1$VAc{6d}xTRzW*l)?!-Zs1DAG%xP-^1>FWF`7t!%! zH>h-u86^N4r6Tyg^&&Y)-#q0q82(;llA;}Wj2~i{zN&x<#KReicjuuLz{xqs>a+hN zka9KDO1zXG2)%1Nf?ac=7Qlx%$hF4Mz&}?+h8ZnVMEARi(}|NI4?!Vzio7b2qq1;i z@dqe7fg~FB^J{fm`=8$? zkEJF~9|8{|tb~6M*5NsR+!{5&xBonqkdOg0T53Ln?UssLD-6dW_0@~8NY}1csK7}+KneIyiXv$m(vrLHC{*Oy?5oFrY}iRK z(bF}*(Kas(oM+2ABZRi%UobH->0iZ&ii&C}C@9QLPk$KWrLp)vFntoQCRv`}8730W z8ghhriSlPdkH<1c_+!_$t*oqqkj~;8=#rljw>pXS%qOu}Uu$b?%RJC=H3Jbw#MXOk zqF%1cc_{h`d;^k9l?JRnZPFxFdZgb`(FkKPa@a_1EjBbV(l>!+HYX=Xv+fIR56Y9c zUo}4N{?3{1!e)a5z@9Yvx?$ zTv_p6CGY@Z`X7V7Eo~Bsl#!V!)Zg1noUp#J-Yd-kC#$GMt`3KNjEPVyj|2wag2FYw{FIsAiygb)03_n@}t&BBUCdSauaqu&Ap zgM(Q=f4X@lo`s1Pe;4UFv=Id{Y=_>h+^VOTfU^Jc>B0b1)F&u;J?Er~B#YP^22M8x zASJ0H%(|KICu(Iu=}(^~BED&2;d+k z=~G9FO~fRb9kzf&kf5QDUUWBk(VKUv)4^K?DQ;QiFwKPq>Qrw9xon&pv^aFenhx3E$YiK|)+=>8H_EZ)(DdeG5MA*ll*e+%8 z#!m6N@N^{{f&g3~wty{wAx=<&7(kjuC5$e7trax&r4uo^H&YWuoFQ(?dZ~$&au57G zCkMq3>Zm8nuEJoo2f=dJ9$4I-Ib5f8lC$nW#vG+qVcdlxHH}W>l~2=$y|m5*;rlE@ zJ{qequ0q9{UlAaeWCy@Z`a`Q}AP_msikIgTHJl6P6f^~O#I5{P{mf%-PUYX%YVv_6~Nrs+NhX7O!yx~3Y1C! literal 0 HcmV?d00001 diff --git a/admin/tool/usertours/pix/t/export.png b/admin/tool/usertours/pix/t/export.png new file mode 100644 index 0000000000000000000000000000000000000000..27fb5290a28b8637c35db1406d60640792e512e8 GIT binary patch literal 266 zcmeAS@N?(olHy`uVBq!ia0vp^JRr=$1|-8uW1a&k$r9IylHmNblJdl&R0hYC{G?O` z&)mfH)S%SFl*+=BsWw1GYdl>XLn>}1r8qDfr=Rm-2>RUkgIU0O+4AM_4%5<)ER + + +]> + + + + + diff --git a/admin/tool/usertours/pix/t/filler.svg b/admin/tool/usertours/pix/t/filler.svg new file mode 100644 index 00000000000..f5c3e5515c7 --- /dev/null +++ b/admin/tool/usertours/pix/t/filler.svg @@ -0,0 +1,12 @@ + + + +]> + + + + diff --git a/admin/tool/usertours/settings.php b/admin/tool/usertours/settings.php new file mode 100644 index 00000000000..219c14a9cf6 --- /dev/null +++ b/admin/tool/usertours/settings.php @@ -0,0 +1,40 @@ +. + +/** + * Settings page. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +$capabilities = [ + 'tool/usertours:managetours', +]; +if ($hassiteconfig || has_any_capability($capabilities, context_system::instance())) { + $ADMIN->add( + 'appearance', + new admin_externalpage( + 'tool_usertours/tours', + get_string('usertours', 'tool_usertours'), + new moodle_url('/admin/tool/usertours/configure.php'), + 'tool/usertours:managetours' + ) + ); +} diff --git a/admin/tool/usertours/styles.css b/admin/tool/usertours/styles.css new file mode 100644 index 00000000000..2edb826880f --- /dev/null +++ b/admin/tool/usertours/styles.css @@ -0,0 +1,68 @@ +div[data-flexitour="backdrop"] { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +span[data-flexitour="container"].orphan { + position: absolute; +} + +span[data-flexitour="container"].orphan div[data-role="arrow"] { + display: none; +} + +body div[data-flexitour="step-background-fader"], +body div[data-flexitour="step-background"] { + position: absolute; + background: inherit; +} + +div[data-flexitour="step-background-fader"], +[data-flexitour="step-backdrop"] > td, +[data-flexitour="step-backdrop"] { + position: relative; +} + +span[data-flexitour="container"].orphan div[data-role="arrow"] { + display: none; +} + +.path-admin-local-usertours .stepcreator .input-append { + font-size: inherit; +} + +.tour-actions > ul { + margin-left: auto; + margin-right: auto; + text-align: center; +} + +.tour-actions > ul > li { + background-color: #f5f5f5; +} + +.tour-actions > ul { + margin-left: auto; + margin-right: auto; + text-align: center; +} + +.tour-actions > ul > li { + background-color: #f5f5f5; + border-radius: 6px; + border: 1px solid #ccc; + display: inline-block; + height: 9em; + margin: .5em; + text-align: center; + width: 13em; +} + +.tour-actions > ul > li > a { + height: 100%; + display: block; + padding: 1em; +} diff --git a/admin/tool/usertours/styles_bootstrapbase.css b/admin/tool/usertours/styles_bootstrapbase.css new file mode 100644 index 00000000000..e69de29bb2d diff --git a/admin/tool/usertours/templates/selecttarget.mustache b/admin/tool/usertours/templates/selecttarget.mustache new file mode 100644 index 00000000000..3086c3a86c8 --- /dev/null +++ b/admin/tool/usertours/templates/selecttarget.mustache @@ -0,0 +1,60 @@ +{{! + 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 . +}} +{{! + @template tool_usertours/selecttarget + + Template for selecting the type of step target in a dialogue. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * targetTypes [object] + ** type + ** title + + Example context (json): + { + "targetType": [ + { + type: 1, + title: "CSS Selector" + } + ] + } + +}} + +
+ +
+ + +
+
+
diff --git a/admin/tool/usertours/templates/tourstep.mustache b/admin/tool/usertours/templates/tourstep.mustache new file mode 100644 index 00000000000..a4fb976cbe8 --- /dev/null +++ b/admin/tool/usertours/templates/tourstep.mustache @@ -0,0 +1,62 @@ +{{! + 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 . +}} +{{! + @template tool_usertours/tourstep + + Template used as a template to render step bubbles with their content + in a user tour. + + Classes required for JS: + * arrow + * popover-title + * popover-content + * popover-navigation + * popover-title + + Data attributes required for JS: + * data-role=prev + * data-role=next + * data-role=pause-resume + * data-pause-text + * data-resume-text + * data-role=end + * data-placeholder=body + * data-placeholder=title + + Context variables required for this template: + * None + + Example context (json): + { + } + +}} + diff --git a/admin/tool/usertours/tests/behat/behat_tool_usertours.php b/admin/tool/usertours/tests/behat/behat_tool_usertours.php new file mode 100644 index 00000000000..63afcbe0123 --- /dev/null +++ b/admin/tool/usertours/tests/behat/behat_tool_usertours.php @@ -0,0 +1,92 @@ +. + +/** + * User tour related steps definitions. + * + * @package tool_usertours + * @category test + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once(__DIR__ . '/../../../../../lib/behat/behat_base.php'); + +use Behat\Gherkin\Node\TableNode as TableNode; +/** + * User tour related steps definitions. + * + * @package tool_usertours + * @category test + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class behat_tool_usertours extends behat_base { + + /** + * Add a new user tour. + * + * @Given /^I add a new user tour with:$/ + * @param TableNode $table + */ + public function i_add_a_new_user_tour_with(TableNode $table) { + $this->execute('behat_tool_usertours::i_open_the_user_tour_settings_page'); + $this->execute('behat_general::click_link', get_string('newtour', 'tool_usertours')); + + // Fill form and post. + $this->execute('behat_forms::i_set_the_following_fields_to_these_values', $table); + $this->execute('behat_forms::press_button', get_string('savechanges', 'moodle')); + $this->execute('behat_general::i_wait_to_be_redirected'); + } + + /** + * Add new steps to a user tour. + * + * @Given /^I add steps to the "(?P(?:[^"]|\\")*)" tour:$/ + * @param string $tourname The name of the tour to add steps to. + * @param TableNode $table + */ + public function i_add_steps_to_the_named_tour($tourname, TableNode $table) { + $this->execute('behat_tool_usertours::i_open_the_user_tour_settings_page'); + $this->execute('behat_general::click_link', $this->escape($tourname)); + + foreach ($table->getHash() as $step) { + $this->execute('behat_general::click_link', get_string('newstep', 'tool_usertours')); + + foreach ($step as $locator => $value) { + $this->execute('behat_forms::i_set_the_field_to', [$this->escape($locator), $this->escape($value)]); + } + + $this->execute('behat_forms::press_button', get_string('savechanges', 'moodle')); + $this->execute('behat_general::i_wait_to_be_redirected'); + } + } + + /** + * Navigate to the user tour settings page. + * + * @Given /^I open the User tour settings page$/ + */ + public function i_open_the_user_tour_settings_page() { + $this->execute('behat_navigation::i_navigate_to_node_in', [ + get_string('usertours', 'tool_usertours'), + implode(' > ', [ + get_string('administrationsite', 'moodle'), + get_string('appearance', 'admin'), + ]) + ]); + } +} diff --git a/admin/tool/usertours/tests/behat/create_tour.feature b/admin/tool/usertours/tests/behat/create_tour.feature new file mode 100644 index 00000000000..4e1693ec154 --- /dev/null +++ b/admin/tool/usertours/tests/behat/create_tour.feature @@ -0,0 +1,81 @@ +@tool @tool_usertours +Feature: Add a new user tour + In order to help users learn of new features + As an administrator + I need to create a user tour + + @javascript + Scenario: Add a new tour + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | student1@example.com | + And I log in as "admin" + And I add a new user tour with: + | Name | First tour | + | Description | My first tour | + | Apply to URL match | /my/% | + | Enabled | 1 | + And I add steps to the "First tour" tour: + | targettype | Title | Content | + | Display in middle of page | Welcome | Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful | + And I add steps to the "First tour" tour: + | targettype | targetvalue_block | Title | Content | + | Block | Course overview | Course overview | This area shows you what's happening in some of your courses | + | Block | Calendar | Calendar | This is the Calendar. All of your assignments and due dates can be found here | + And I add steps to the "First tour" tour: + | targettype | targetvalue_selector | Title | Content | + | Selector | .usermenu | User menu | This is your personal user menu. You'll find your personal preferences and your user profile here. | + When I click on "Dashboard" "link" in the "Navigation" "block" + Then I should see "Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful" + And I press "Next" + And I should see "This area shows you what's happening in some of your courses" + And I should not see "This is the Calendar. All of your assignments and due dates can be found here" + And I press "Next" + And I should see "This is the Calendar. All of your assignments and due dates can be found here" + And I should not see "This area shows you what's happening in some of your courses" + And I press "Prev" + And I should not see "This is the Calendar. All of your assignments and due dates can be found here" + And I should see "This area shows you what's happening in some of your courses" + And I press "End tour" + And I should not see "This area shows you what's happening in some of your courses" + And I click on "Dashboard" "link" in the "Navigation" "block" + And I should not see "Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful" + And I should not see "This area shows you what's happening in some of your courses" + And I follow "Reset user tour on this page" + And I should see "Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful" + + @javascript + Scenario: A hidden tour should not be visible + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | student1@example.com | + And I log in as "admin" + And I add a new user tour with: + | Name | First tour | + | Description | My first tour | + | Apply to URL match | /my/% | + | Enabled | 0 | + And I add steps to the "First tour" tour: + | targettype | Title | Content | + | Display in middle of page | Welcome | Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful | + When I click on "Dashboard" "link" in the "Navigation" "block" + Then I should not see "Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful" + + @javascript + Scenario: Tour visibility can be toggled + Given the following "users" exist: + | username | firstname | lastname | email | + | student1 | Student | 1 | student1@example.com | + And I log in as "admin" + And I add a new user tour with: + | Name | First tour | + | Description | My first tour | + | Apply to URL match | /my/% | + | Enabled | 0 | + And I add steps to the "First tour" tour: + | targettype | Title | Content | + | Display in middle of page | Welcome | Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful | + And I open the User tour settings page + When I click on "Enable" "link" in the "My first tour" "table_row" + And I click on "Dashboard" "link" in the "Navigation" "block" + Then I should see "Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful" diff --git a/admin/tool/usertours/tests/behat/tour_filter.feature b/admin/tool/usertours/tests/behat/tour_filter.feature new file mode 100644 index 00000000000..81eddcd3401 --- /dev/null +++ b/admin/tool/usertours/tests/behat/tour_filter.feature @@ -0,0 +1,62 @@ +@tool @tool_usertours +Feature: Apply tour filters to a tour + In order to give more directed tours + As an administrator + I need to create a user tour with filters applied + + @javascript + Scenario: Add a tour for a different theme + Given I log in as "admin" + And I add a new user tour with: + | Name | First tour | + | Description | My first tour | + | Apply to URL match | /my/% | + | Enabled | 1 | + | Theme | More | + And I add steps to the "First tour" tour: + | targettype | Title | Content | + | Display in middle of page | Welcome | Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful | + When I click on "Dashboard" "link" in the "Navigation" "block" + Then I should not see "Welcome to your personal learning space. We'd like to give you a quick tour to show you some of the areas you may find helpful" + + @javascript + Scenario: Add a tour for a specific role + Given the following "courses" exist: + | fullname | shortname | format | enablecompletion | + | Course 1 | C1 | topics | 1 | + And the following "users" exist: + | username | + | editor1 | + | teacher1 | + | student1 | + And the following "course enrolments" exist: + | user | course | role | + | editor1 | C1 | editingteacher | + | teacher1 | C1 | teacher | + | student1 | C1 | student | + And I log in as "admin" + And I add a new user tour with: + | Name | First tour | + | Description | My first tour | + | Apply to URL match | /course/view.php% | + | Enabled | 1 | + | Role | Student,Non-editing teacher | + And I add steps to the "First tour" tour: + | targettype | Title | Content | + | Display in middle of page | Welcome | Welcome to your course tour.| + And I log out + And I log in as "editor1" + And I am on site homepage + When I follow "Course 1" + Then I should not see "Welcome to your course tour." + And I log out + And I log in as "student1" + And I am on site homepage + And I follow "Course 1" + And I should see "Welcome to your course tour." + And I click on "End tour" "button" + And I log out + And I log in as "teacher1" + And I am on site homepage + And I follow "Course 1" + And I should see "Welcome to your course tour." diff --git a/admin/tool/usertours/tests/manager_test.php b/admin/tool/usertours/tests/manager_test.php new file mode 100644 index 00000000000..b550fb7d974 --- /dev/null +++ b/admin/tool/usertours/tests/manager_test.php @@ -0,0 +1,280 @@ +. + +/** + * Tests for manager. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/formslib.php'); + +/** + * Tests for step. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class manager_testcase extends advanced_testcase { + + /** + * @var moodle_database + */ + protected $db; + + /** + * Setup to store the DB reference. + */ + public function setUp() { + global $DB; + + $this->db = $DB; + } + + /** + * Tear down to restore the original DB reference. + */ + public function tearDown() { + global $DB; + + $DB = $this->db; + } + + /** + * Helper to mock the database. + * + * @return moodle_database + */ + public function mock_database() { + global $DB; + + $DB = $this->getMockBuilder('moodle_database')->getMock(); + + return $DB; + } + + /** + * Data provider to ensure that all modification actions require the session key. + * + * @return array + */ + public function sesskey_required_provider() { + $tourid = rand(1, 100); + $stepid = rand(1, 100); + + return [ + 'Tour removal' => [ + 'delete_tour', + [$tourid], + ], + 'Step removal' => [ + 'delete_step', + [$stepid], + ], + 'Tour visibility' => [ + 'show_hide_tour', + [$tourid, true], + ], + 'Move step' => [ + 'move_step', + [$stepid], + ], + ]; + } + + /** + * Ensure that all modification actions require the session key. + * + * @dataProvider sesskey_required_provider + * @param string $function The function to test + * @param array $arguments The arguments to pass with it + */ + public function test_sesskey_required($function, $arguments) { + $manager = new \tool_usertours\manager(); + + $rc = new \ReflectionClass('\tool_usertours\manager'); + $rcm = $rc->getMethod($function); + $rcm->setAccessible(true); + + $this->setExpectedException('moodle_exception', 'A required parameter (sesskey) was missing'); + $rcm->invokeArgs($manager, $arguments); + } + + /** + * Data Provider for get_matching_tours tests. + * + * @return array + */ + public function get_matching_tours_provider() { + global $CFG; + + $alltours = [ + [ + 'pathmatch' => '/my/%', + 'enabled' => false, + 'name' => 'Failure', + 'description' => '', + 'configdata' => '', + ], + [ + 'pathmatch' => '/my/%', + 'enabled' => true, + 'name' => 'My tour enabled', + 'description' => '', + 'configdata' => '', + ], + [ + 'pathmatch' => '/my/%', + 'enabled' => false, + 'name' => 'Failure', + 'description' => '', + 'configdata' => '', + ], + [ + 'pathmatch' => '/course/?id=%foo=bar', + 'enabled' => false, + 'name' => 'Failure', + 'description' => '', + 'configdata' => '', + ], + [ + 'pathmatch' => '/course/?id=%foo=bar', + 'enabled' => true, + 'name' => 'course tour with additional params enabled', + 'description' => '', + 'configdata' => '', + ], + [ + 'pathmatch' => '/course/?id=%foo=bar', + 'enabled' => false, + 'name' => 'Failure', + 'description' => '', + 'configdata' => '', + ], + [ + 'pathmatch' => '/course/?id=%', + 'enabled' => false, + 'name' => 'Failure', + 'description' => '', + 'configdata' => '', + ], + [ + 'pathmatch' => '/course/?id=%', + 'enabled' => true, + 'name' => 'course tour enabled', + 'description' => '', + 'configdata' => '', + ], + [ + 'pathmatch' => '/course/?id=%', + 'enabled' => false, + 'name' => 'Failure', + 'description' => '', + 'configdata' => '', + ], + ]; + + return [ + 'No matches found' => [ + $alltours, + $CFG->wwwroot . '/some/invalid/value', + null, + ], + 'Never return a disabled tour' => [ + $alltours, + $CFG->wwwroot . '/my/index.php', + 'My tour enabled', + ], + 'My not course' => [ + $alltours, + $CFG->wwwroot . '/my/index.php', + 'My tour enabled', + ], + 'My with params' => [ + $alltours, + $CFG->wwwroot . '/my/index.php?id=42', + 'My tour enabled', + ], + 'Course with params' => [ + $alltours, + $CFG->wwwroot . '/course/?id=42', + 'course tour enabled', + ], + 'Course with params and trailing content' => [ + $alltours, + $CFG->wwwroot . '/course/?id=42&foo=bar', + 'course tour with additional params enabled', + ], + ]; + } + + /** + * Tests for the get_matching_tours function. + * + * @dataProvider get_matching_tours_provider + * @param array $alltours The list of tours to insert + * @param string $url The URL to test + * @param string $expected The name of the expected matching tour + */ + public function test_get_matching_tours($alltours, $url, $expected) { + $this->resetAfterTest(); + + foreach ($alltours as $tourconfig) { + $tourconfig = (object) $tourconfig; + $tourconfig->id = null; + $tour = \tool_usertours\tour::load_from_record($tourconfig, true); + $tour->persist(true); + } + + $match = \tool_usertours\manager::get_matching_tours(new moodle_url($url)); + if ($expected === null) { + $this->assertNull($match); + } else { + $this->assertEquals($expected, $match->get_name()); + } + } + + /** + * Tests for the get_matching_tours function when requiring an upgrade + * + * @dataProvider get_matching_tours_provider + * @param array $alltours The list of tours to insert + * @param string $url The URL to test + * @param string $expected The name of the expected matching tour + */ + public function test_get_matching_tours_requires_upgrade($alltours, $url, $expected) { + $this->resetAfterTest(); + + global $CFG; + unset($CFG->version); + + foreach ($alltours as $tourconfig) { + $tourconfig = (object) $tourconfig; + $tourconfig->id = null; + $tour = \tool_usertours\tour::load_from_record($tourconfig, true); + $tour->persist(true); + } + + $this->assertNull(\tool_usertours\manager::get_matching_tours(new moodle_url($url))); + } + +} diff --git a/admin/tool/usertours/tests/role_filter_test.php b/admin/tool/usertours/tests/role_filter_test.php new file mode 100644 index 00000000000..a27d6f0de75 --- /dev/null +++ b/admin/tool/usertours/tests/role_filter_test.php @@ -0,0 +1,188 @@ +. + +/** + * Tests for role filter. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests for role filter. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class tool_usertours_role_filter_testcase extends advanced_testcase { + + /** + * @var $course Test course + */ + protected $course; + + /** + * @var $student Test student + */ + protected $student; + + /** + * @var $teacher Test teacher + */ + protected $teacher; + + /** + * @var $editingteacher Test editor + */ + protected $editingteacher; + + /** + * @var $roles List of all roles + */ + protected $roles; + + public function setUp() { + global $DB; + + $this->resetAfterTest(true); + $generator = $this->getDataGenerator(); + + $this->course = $generator->create_course(); + $this->roles = $DB->get_records_menu('role', [], null, 'shortname, id'); + $this->testroles = ['student', 'teacher', 'editingteacher']; + + foreach ($this->testroles as $role) { + $user = $this->$role = $generator->create_user(); + $generator->enrol_user($user->id, $this->course->id, $this->roles[$role]); + } + } + + /** + * Test the filter_matches function when any is set. + */ + public function test_filter_matches_any() { + $context = \context_course::instance($this->course->id); + + // Note: No need to persist this tour. + $tour = new \tool_usertours\tour(); + $tour->set_filter_values('role', []); + + // Note: The role filter does not use the context. + foreach ($this->testroles as $role) { + $this->setUser($this->$role); + $this->assertTrue(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } + + // The admin should always be able to view too. + $this->setAdminUser(); + $this->assertTrue(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } + + /** + * Test the filter_matches function when one role is set. + */ + public function test_filter_matches_single_role() { + $context = \context_course::instance($this->course->id); + + $roles = [ + $this->roles['student'], + ]; + + // Note: No need to persist this tour. + $tour = new \tool_usertours\tour(); + $tour->set_filter_values('role', $roles); + + // Note: The role filter does not use the context. + foreach ($this->testroles as $role) { + $this->setUser($this->$role); + if ($role === 'student') { + $this->assertTrue(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } else { + $this->assertFalse(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } + } + + // The admin should always be able to view too. + $this->setAdminUser(); + $this->assertTrue(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } + + /** + * Test the filter_matches function when multiple roles are set. + */ + public function test_filter_matches_multiple_role() { + $context = \context_course::instance($this->course->id); + + $roles = [ + $this->roles['teacher'], + $this->roles['editingteacher'], + ]; + + // Note: No need to persist this tour. + $tour = new \tool_usertours\tour(); + $tour->set_filter_values('role', $roles); + + // Note: The role filter does not use the context. + foreach ($this->testroles as $role) { + $this->setUser($this->$role); + if ($role === 'student') { + $this->assertFalse(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } else { + $this->assertTrue(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } + } + + // The admin should always be able to view too. + $this->setAdminUser(); + $this->assertTrue(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } + + /** + * Test the filter_matches function when one user has multiple roles. + */ + public function test_filter_matches_multiple_role_one_user() { + $context = \context_course::instance($this->course->id); + + $roles = [ + $this->roles['student'], + ]; + + $this->getDataGenerator()->enrol_user($this->student->id, $this->course->id, $this->roles['teacher']); + + // Note: No need to persist this tour. + $tour = new \tool_usertours\tour(); + $tour->set_filter_values('role', $roles); + + + // Note: The role filter does not use the context. + foreach ($this->testroles as $role) { + $this->setUser($this->$role); + if ($role === 'student') { + $this->assertTrue(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } else { + $this->assertFalse(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } + } + + // The admin should always be able to view too. + $this->setAdminUser(); + $this->assertTrue(\tool_usertours\local\filter\role::filter_matches($tour, $context)); + } +} diff --git a/admin/tool/usertours/tests/step_output_test.php b/admin/tool/usertours/tests/step_output_test.php new file mode 100644 index 00000000000..b14af63b1b1 --- /dev/null +++ b/admin/tool/usertours/tests/step_output_test.php @@ -0,0 +1,84 @@ +. + +/** + * Tests for step. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests for step. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class step_output_testcase extends advanced_testcase { + + /** + * Data Provider for get_string_from_inpu. + * + * @return array + */ + public function get_string_from_input_provider() { + return [ + 'Text' => [ + 'example', + 'example', + ], + 'Text which looks like a langstring' => [ + 'example,fakecomponent', + 'example,fakecomponent', + ], + 'Text which is a langstring' => [ + 'administration,core', + 'Administration', + ], + 'Text which is a langstring but uses "moodle" instead of "core"' => [ + 'administration,moodle', + 'Administration', + ], + 'Text which is a langstring, but with extra whitespace' => [ + ' administration,moodle ', + 'Administration', + ], + 'Looks like a langstring, but has incorrect space around comma' => [ + 'administration , moodle', + 'administration , moodle', + ], + ]; + } + + /** + * Ensure that the get_string_from_input function returns translated + * strings correctly. + * + * @dataProvider get_string_from_input_provider + * @param string $string The string to test + * @param string $expected The expected result + */ + public function test_get_string_from_input($string, $expected) { + $rc = new ReflectionClass('\\tool_usertours\\output\\step'); + $rcm = $rc->getMethod('get_string_from_input'); + $rcm->setAccessible(true); + $this->assertEquals($expected, $rcm->invoke(null, $string)); + } +} diff --git a/admin/tool/usertours/tests/step_test.php b/admin/tool/usertours/tests/step_test.php new file mode 100644 index 00000000000..444d2dab669 --- /dev/null +++ b/admin/tool/usertours/tests/step_test.php @@ -0,0 +1,806 @@ +. + +/** + * Tests for step. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/formslib.php'); + +/** + * Tests for step. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class step_testcase extends advanced_testcase { + + /** + * @var moodle_database + */ + protected $db; + + /** + * Setup to store the DB reference. + */ + public function setUp() { + global $DB; + + $this->db = $DB; + } + + /** + * Tear down to restore the original DB reference. + */ + public function tearDown() { + global $DB; + + $DB = $this->db; + } + + /** + * Helper to mock the database. + * + * @return moodle_database + */ + public function mock_database() { + global $DB; + + $DB = $this->getMockBuilder('moodle_database') + ->getMock() + ; + + return $DB; + } + + /** + * Data provider for the dirty value tester. + * + * @return array + */ + public function dirty_value_provider() { + return [ + 'tourid' => [ + 'tourid', + [1], + ], + 'title' => [ + 'title', + ['Lorem'], + ], + 'content' => [ + 'content', + ['Lorem'], + ], + 'targettype' => [ + 'targettype', + ['Lorem'], + ], + 'targetvalue' => [ + 'targetvalue', + ['Lorem'], + ], + 'sortorder' => [ + 'sortorder', + [1], + ], + 'config' => [ + 'config', + ['key', 'value'], + ], + ]; + } + + /** + * Test the fetch function. + */ + public function test_fetch() { + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods(['reload_from_record']) + ->getMock() + ; + + $idretval = rand(1, 100); + $DB = $this->mock_database(); + $DB->method('get_record') + ->willReturn($idretval) + ; + + $retval = rand(1, 100); + $step->expects($this->once()) + ->method('reload_from_record') + ->with($this->equalTo($idretval)) + ->wilLReturn($retval) + ; + + $rc = new \ReflectionClass(\tool_usertours\step::class); + $rcm = $rc->getMethod('fetch'); + $rcm->setAccessible(true); + + $id = rand(1, 100); + $this->assertEquals($retval, $rcm->invoke($step, 'fetch', $id)); + } + + /** + * Test that setters mark things as dirty. + * + * @dataProvider dirty_value_provider + * @param string $name The key to update + * @param string $value The value to set + */ + public function test_dirty_values($name, $value) { + $step = new \tool_usertours\step(); + $method = 'set_' . $name; + call_user_func_array([$step, $method], $value); + + $rc = new \ReflectionClass(\tool_usertours\step::class); + $rcp = $rc->getProperty('dirty'); + $rcp->setAccessible(true); + + $this->assertTrue($rcp->getValue($step)); + } + + /** + * Provider for is_first_step. + * + * @return array + */ + public function step_sortorder_provider() { + return [ + [0, 5, true, false], + [1, 5, false, false], + [4, 5, false, true], + ]; + } + + /** + * Test is_first_step. + * + * @dataProvider step_sortorder_provider + * @param int $sortorder The sortorder to check + * @param int $count Unused in this function + * @param bool $isfirst Whether this is the first step + * @param bool $islast Whether this is the last step + */ + public function test_is_first_step($sortorder, $count, $isfirst, $islast) { + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods(['get_sortorder']) + ->getMock(); + + $step->expects($this->once()) + ->method('get_sortorder') + ->willReturn($sortorder) + ; + + $this->assertEquals($isfirst, $step->is_first_step()); + } + + /** + * Test is_last_step. + * + * @dataProvider step_sortorder_provider + * @param int $sortorder The sortorder to check + * @param int $count Total number of steps for this test + * @param bool $isfirst Whether this is the first step + * @param bool $islast Whether this is the last step + */ + public function test_is_last_step($sortorder, $count, $isfirst, $islast) { + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods(['get_sortorder', 'get_tour']) + ->getMock(); + + $tour = $this->getMockBuilder(\tool_usertours\tour::class) + ->setMethods(['count_steps']) + ->getMock(); + + $step->expects($this->once()) + ->method('get_tour') + ->willReturn($tour) + ; + + $tour->expects($this->once()) + ->method('count_steps') + ->willReturn($count) + ; + + $step->expects($this->once()) + ->method('get_sortorder') + ->willReturn($sortorder) + ; + + $this->assertEquals($islast, $step->is_last_step()); + } + + /** + * Test get_config with no keys provided. + */ + public function test_get_config_no_keys() { + $step = new \tool_usertours\step(); + + $rc = new \ReflectionClass(\tool_usertours\step::class); + $rcp = $rc->getProperty('config'); + $rcp->setAccessible(true); + + $allvalues = (object) [ + 'some' => 'value', + 'another' => 42, + 'key' => [ + 'somethingelse', + ], + ]; + + $rcp->setValue($step, $allvalues); + + $this->assertEquals($allvalues, $step->get_config()); + } + + /** + * Data provider for get_config. + * + * @return array + */ + public function get_config_provider() { + $allvalues = (object) [ + 'some' => 'value', + 'another' => 42, + 'key' => [ + 'somethingelse', + ], + ]; + + $tourconfig = rand(1, 100); + $forcedconfig = rand(1, 100); + + return [ + 'No initial config' => [ + null, + null, + null, + $tourconfig, + false, + $forcedconfig, + (object) [], + ], + 'All values' => [ + $allvalues, + null, + null, + $tourconfig, + false, + $forcedconfig, + $allvalues, + ], + 'Valid string value' => [ + $allvalues, + 'some', + null, + $tourconfig, + false, + $forcedconfig, + 'value', + ], + 'Valid array value' => [ + $allvalues, + 'key', + null, + $tourconfig, + false, + $forcedconfig, + ['somethingelse'], + ], + 'Invalid value' => [ + $allvalues, + 'notavalue', + null, + $tourconfig, + false, + $forcedconfig, + $tourconfig, + ], + 'Configuration value' => [ + $allvalues, + 'placement', + null, + $tourconfig, + false, + $forcedconfig, + $tourconfig, + ], + 'Invalid value with default' => [ + $allvalues, + 'notavalue', + 'somedefault', + $tourconfig, + false, + $forcedconfig, + 'somedefault', + ], + 'Value forced at target' => [ + $allvalues, + 'somevalue', + 'somedefault', + $tourconfig, + true, + $forcedconfig, + $forcedconfig, + ], + ]; + } + + /** + * Test get_config with valid keys provided. + * + * @dataProvider get_config_provider + * @param object $values The config values + * @param string $key The key + * @param mixed $default The default value + * @param mixed $tourconfig The tour config + * @param bool $isforced Whether the setting is forced + * @param mixed $forcedvalue The example value + * @param mixed $expected The expected value + */ + public function test_get_config_valid_keys($values, $key, $default, $tourconfig, $isforced, $forcedvalue, $expected) { + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods(['get_target', 'get_targettype', 'get_tour']) + ->getMock(); + + $rc = new \ReflectionClass(\tool_usertours\step::class); + $rcp = $rc->getProperty('config'); + $rcp->setAccessible(true); + $rcp->setValue($step, $values); + + $target = $this->getMockBuilder(\tool_usertours\local\target\base::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $target->expects($this->any()) + ->method('is_setting_forced') + ->willReturn($isforced) + ; + + $target->expects($this->any()) + ->method('get_forced_setting_value') + ->with($this->equalTo($key)) + ->willReturn($forcedvalue) + ; + + $step->expects($this->any()) + ->method('get_targettype') + ->willReturn('type') + ; + + $step->expects($this->any()) + ->method('get_target') + ->willReturn($target) + ; + + $tour = $this->getMockBuilder(\tool_usertours\tour::class) + ->getMock() + ; + + $tour->expects($this->any()) + ->method('get_config') + ->willReturn($tourconfig) + ; + + $step->expects($this->any()) + ->method('get_tour') + ->willReturn($tour) + ; + + $this->assertEquals($expected, $step->get_config($key, $default)); + } + + /** + * Data provider for set_config. + */ + public function set_config_provider() { + $allvalues = (object) [ + 'some' => 'value', + 'another' => 42, + 'key' => [ + 'somethingelse', + ], + ]; + + $randvalue = rand(1, 100); + + $provider = []; + + $newvalues = $allvalues; + $newvalues->some = 'unset'; + $provider['Unset an existing value'] = [ + $allvalues, + 'some', + null, + $newvalues, + ]; + + $newvalues = $allvalues; + $newvalues->some = $randvalue; + $provider['Set an existing value'] = [ + $allvalues, + 'some', + $randvalue, + $newvalues, + ]; + + $provider['Set a new value'] = [ + $allvalues, + 'newkey', + $randvalue, + (object) array_merge((array) $allvalues, ['newkey' => $randvalue]), + ]; + + return $provider; + } + + /** + * Test that set_config works in the anticipated fashion. + * + * @dataProvider set_config_provider + * @param mixed $initialvalues The inital value to set + * @param string $key The key to test + * @param mixed $newvalue The new value to set + * @param mixed $expected The expected value + */ + public function test_set_config($initialvalues, $key, $newvalue, $expected) { + $step = new \tool_usertours\step(); + + $rc = new \ReflectionClass(\tool_usertours\step::class); + $rcp = $rc->getProperty('config'); + $rcp->setAccessible(true); + $rcp->setValue($step, $initialvalues); + + $target = $this->getMockBuilder(\tool_usertours\local\target\base::class) + ->disableOriginalConstructor() + ->getMock() + ; + + $target->expects($this->any()) + ->method('is_setting_forced') + ->willReturn(false) + ; + + $step->set_config($key, $newvalue); + + $this->assertEquals($expected, $rcp->getValue($step)); + } + + /** + * Ensure that non-dirty tours are not persisted. + */ + public function test_persist_non_dirty() { + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods([ + 'to_record', + 'reload', + ]) + ->getMock() + ; + + $step->expects($this->never()) + ->method('to_record') + ; + + $step->expects($this->never()) + ->method('reload') + ; + + $this->assertSame($step, $step->persist()); + } + + /** + * Ensure that new dirty steps are persisted. + */ + public function test_persist_dirty_new() { + // Mock the database. + $DB = $this->mock_database(); + $DB->expects($this->once()) + ->method('insert_record') + ->willReturn(42) + ; + + // Mock the tour. + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods([ + 'to_record', + 'calculate_sortorder', + 'reload', + ]) + ->getMock() + ; + + $step->expects($this->once()) + ->method('to_record') + ->willReturn((object)['id' => 42]); + ; + + $step->expects($this->once()) + ->method('calculate_sortorder') + ; + + $step->expects($this->once()) + ->method('reload') + ; + + $rc = new \ReflectionClass(\tool_usertours\step::class); + $rcp = $rc->getProperty('dirty'); + $rcp->setAccessible(true); + $rcp->setValue($step, true); + + $this->assertSame($step, $step->persist()); + } + + /** + * Ensure that new non-dirty, forced steps are persisted. + */ + public function test_persist_force_new() { + global $DB; + + // Mock the database. + $DB = $this->mock_database(); + $DB->expects($this->once()) + ->method('insert_record') + ->willReturn(42) + ; + + // Mock the tour. + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods([ + 'to_record', + 'calculate_sortorder', + 'reload', + ]) + ->getMock() + ; + + $step->expects($this->once()) + ->method('to_record') + ->willReturn((object)['id' => 42]); + ; + + $step->expects($this->once()) + ->method('calculate_sortorder') + ; + + $step->expects($this->once()) + ->method('reload') + ; + + $this->assertSame($step, $step->persist(true)); + } + + /** + * Ensure that existing dirty steps are persisted. + */ + public function test_persist_dirty_existing() { + // Mock the database. + $DB = $this->mock_database(); + $DB->expects($this->once()) + ->method('update_record') + ; + + // Mock the tour. + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods([ + 'to_record', + 'calculate_sortorder', + 'reload', + ]) + ->getMock() + ; + + $step->expects($this->once()) + ->method('to_record') + ->willReturn((object)['id' => 42]); + ; + + $step->expects($this->never()) + ->method('calculate_sortorder') + ; + + $step->expects($this->once()) + ->method('reload') + ; + + $rc = new \ReflectionClass(\tool_usertours\step::class); + $rcp = $rc->getProperty('id'); + $rcp->setAccessible(true); + $rcp->setValue($step, 42); + + $rcp = $rc->getProperty('dirty'); + $rcp->setAccessible(true); + $rcp->setValue($step, true); + + $this->assertSame($step, $step->persist()); + } + + /** + * Ensure that existing non-dirty, forced steps are persisted. + */ + public function test_persist_force_existing() { + global $DB; + + // Mock the database. + $DB = $this->mock_database(); + $DB->expects($this->once()) + ->method('update_record') + ; + + // Mock the tour. + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods([ + 'to_record', + 'calculate_sortorder', + 'reload', + ]) + ->getMock() + ; + + $step->expects($this->once()) + ->method('to_record') + ->willReturn((object)['id' => 42]); + ; + + $step->expects($this->never()) + ->method('calculate_sortorder') + ; + + $step->expects($this->once()) + ->method('reload') + ; + + $rc = new \ReflectionClass(\tool_usertours\step::class); + $rcp = $rc->getProperty('id'); + $rcp->setAccessible(true); + $rcp->setValue($step, 42); + + $this->assertSame($step, $step->persist(true)); + } + + /** + * Check that a tour which has never been persisted is removed correctly. + */ + public function test_remove_non_persisted() { + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods(null) + ->getMock() + ; + + // Mock the database. + $DB = $this->mock_database(); + $DB->expects($this->never()) + ->method('delete_records') + ; + + $this->assertNull($step->remove()); + } + + /** + * Check that a tour which has been persisted is removed correctly. + */ + public function test_remove_persisted() { + $id = rand(1, 100); + + $tour = $this->getMockBuilder(\tool_usertours\tour::class) + ->setMethods([ + 'reset_step_sortorder', + ]) + ->getMock() + ; + + $tour->expects($this->once()) + ->method('reset_step_sortorder') + ; + + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods([ + 'get_tour', + ]) + ->getMock() + ; + + $step->expects($this->once()) + ->method('get_tour') + ->willReturn($tour) + ; + + // Mock the database. + $DB = $this->mock_database(); + $DB->expects($this->once()) + ->method('delete_records') + ->with($this->equalTo('tool_usertours_steps'), $this->equalTo(['id' => $id])) + ; + + $rc = new \ReflectionClass(\tool_usertours\step::class); + $rcp = $rc->getProperty('id'); + $rcp->setAccessible(true); + $rcp->setValue($step, $id); + + $this->assertEquals($id, $step->get_id()); + $this->assertNull($step->remove()); + } + + /** + * Data provider for the get_ tests. + * + * @return array + */ + public function getter_provider() { + return [ + 'id' => [ + 'id', + rand(1, 100), + ], + 'tourid' => [ + 'tourid', + rand(1, 100), + ], + 'title' => [ + 'title', + 'Lorem', + ], + 'content' => [ + 'content', + 'Lorem', + ], + 'targettype' => [ + 'targettype', + 'Lorem', + ], + 'targetvalue' => [ + 'targetvalue', + 'Lorem', + ], + 'sortorder' => [ + 'sortorder', + rand(1, 100), + ], + ]; + } + + /** + * Test that getters return the configured value. + * + * @dataProvider getter_provider + * @param string $key The key to test + * @param mixed $value The expected value + */ + public function test_getters($key, $value) { + $step = new \tool_usertours\step(); + + $rc = new \ReflectionClass(\tool_usertours\step::class); + + $rcp = $rc->getProperty($key); + $rcp->setAccessible(true); + $rcp->setValue($step, $value); + + $getter = 'get_' . $key; + + $this->assertEquals($value, $step->$getter()); + } + +} diff --git a/admin/tool/usertours/tests/theme_filter_test.php b/admin/tool/usertours/tests/theme_filter_test.php new file mode 100644 index 00000000000..02625cfc09b --- /dev/null +++ b/admin/tool/usertours/tests/theme_filter_test.php @@ -0,0 +1,100 @@ +. + +/** + * Tests for theme filter. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +/** + * Tests for theme filter. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class tool_usertours_theme_filter_testcase extends advanced_testcase { + + /** + * Data Provider for filter_matches function. + * + * @return array + */ + public function filter_matches_provider() { + return [ + 'No config set; Matches' => [ + null, + 'boost', + true, + ], + 'Empty config set; Matches' => [ + [], + 'boost', + true, + ], + 'Single matching value set; Matches' => [ + ['boost'], + 'boost', + true, + ], + 'Multiple values set including matching; Matches' => [ + ['boost', 'clean'], + 'boost', + true, + ], + 'Single value set; No match' => [ + ['clean'], + 'boost', + false, + ], + 'Multiple values set; No match' => [ + ['clean', 'artificial'], + 'boost', + false, + ], + ]; + } + + /** + * Test the filter_matches function. + * + * @dataProvider filter_matches_provider + * @param array $filtervalues The filter values + * @param string $currenttheme The name of the current theme + * @param boolean $expected Whether the tour is expected to match + */ + public function test_filter_matches($filtervalues, $currenttheme, $expected) { + global $PAGE; + + $filtername = \tool_usertours\local\filter\theme::class; + + // Note: No need to persist this tour. + $tour = new \tool_usertours\tour(); + if ($filtervalues !== null) { + $tour->set_filter_values('theme', $filtervalues); + } + + $PAGE->theme->name = $currenttheme; + + // Note: The theme filter does not use the context. + $this->assertEquals($expected, $filtername::filter_matches($tour, \context_system::instance())); + } +} diff --git a/admin/tool/usertours/tests/tour_test.php b/admin/tool/usertours/tests/tour_test.php new file mode 100644 index 00000000000..75a64948c87 --- /dev/null +++ b/admin/tool/usertours/tests/tour_test.php @@ -0,0 +1,1111 @@ +. + +/** + * Tests for tour. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->libdir . '/formslib.php'); + +use tool_usertours\tour; + +/** + * Tests for tour. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class tour_testcase extends advanced_testcase { + + /** + * @var moodle_database + */ + protected $db; + + /** + * Setup to store the DB reference. + */ + public function setUp() { + global $DB; + + $this->db = $DB; + } + + /** + * Tear down to restore the original DB reference. + */ + public function tearDown() { + global $DB; + + $DB = $this->db; + } + + /** + * Helper to mock the database. + * + * @return moodle_database + */ + public function mock_database() { + global $DB; + + $DB = $this->getMockBuilder(\moodle_database::class) + ->getMock() + ; + + return $DB; + } + + /** + * Data provider for the dirty value tester. + * + * @return array + */ + public function dirty_value_provider() { + return [ + 'name' => [ + 'name', + ['Lorem'], + ], + 'description' => [ + 'description', + ['Lorem'], + ], + 'pathmatch' => [ + 'pathmatch', + ['Lorem'], + ], + 'enabled' => [ + 'enabled', + ['Lorem'], + ], + 'sortorder' => [ + 'sortorder', + [1], + ], + 'config' => [ + 'config', + ['key', 'value'], + ], + ]; + } + + /** + * Test that setters mark things as dirty. + * + * @dataProvider dirty_value_provider + * @param string $name The name of the key being tested + * @param mixed $value The value being set + */ + public function test_dirty_values($name, $value) { + $tour = new \tool_usertours\tour(); + $method = 'set_' . $name; + call_user_func_array([$tour, $method], $value); + + $rc = new \ReflectionClass(\tool_usertours\tour::class); + $rcp = $rc->getProperty('dirty'); + $rcp->setAccessible(true); + + $this->assertTrue($rcp->getValue($tour)); + } + + /** + * Data provider for the get_ tests. + * + * @return array + */ + public function getter_provider() { + return [ + 'id' => [ + 'id', + rand(1, 100), + ], + 'name' => [ + 'name', + 'Lorem', + ], + 'description' => [ + 'description', + 'Lorem', + ], + 'pathmatch' => [ + 'pathmatch', + 'Lorem', + ], + 'enabled' => [ + 'enabled', + 'Lorem', + ], + 'sortorder' => [ + 'sortorder', + rand(1, 100), + ], + 'config' => [ + 'config', + ['key', 'value'], + ], + ]; + } + + /** + * Test that getters return the configured value. + * + * @dataProvider getter_provider + * @param string $key The name of the key being tested + * @param mixed $value The value being set + */ + public function test_getters($key, $value) { + $tour = new \tool_usertours\tour(); + + $rc = new \ReflectionClass(tour::class); + + $rcp = $rc->getProperty($key); + $rcp->setAccessible(true); + $rcp->setValue($tour, $value); + + $getter = 'get_' . $key; + + $this->assertEquals($value, $tour->$getter()); + } + + /** + * Ensure that non-dirty tours are not persisted. + */ + public function test_persist_non_dirty() { + $tour = $this->getMockBuilder(tour::class) + ->setMethods(['to_record']) + ->getMock() + ; + + $tour->expects($this->never()) + ->method('to_record') + ; + + $this->assertSame($tour, $tour->persist()); + } + + /** + * Ensure that new dirty tours are persisted. + */ + public function test_persist_dirty_new() { + // Mock the database. + $DB = $this->mock_database(); + + $DB->expects($this->never()) + ->method('update_record') + ; + + $id = rand(1, 100); + $DB->expects($this->once()) + ->method('insert_record') + ->willReturn($id) + ; + + // Mock the tour. + $tour = $this->getMockBuilder(tour::class) + ->setMethods([ + 'to_record', + 'reload', + ]) + ->getMock() + ; + + $tour->expects($this->once()) + ->method('to_record') + ; + + $tour->expects($this->once()) + ->method('reload') + ; + + $rc = new \ReflectionClass(tour::class); + + $rcp = $rc->getProperty('dirty'); + $rcp->setAccessible(true); + $rcp->setValue($tour, true); + + $this->assertSame($tour, $tour->persist()); + + $rcp = $rc->getProperty('id'); + $rcp->setAccessible(true); + $this->assertEquals($id, $rcp->getValue($tour)); + } + + /** + * Ensure that non-dirty, forced tours are persisted. + */ + public function test_persist_force_new() { + global $DB; + + // Mock the database. + $DB = $this->mock_database(); + + $DB->expects($this->never()) + ->method('update_record') + ; + + $id = rand(1, 100); + $DB->expects($this->once()) + ->method('insert_record') + ->willReturn($id) + ; + + // Mock the tour. + $tour = $this->getMockBuilder(tour::class) + ->setMethods([ + 'to_record', + 'reload', + ]) + ->getMock() + ; + + $tour->expects($this->once()) + ->method('to_record') + ; + + $tour->expects($this->once()) + ->method('reload') + ; + + $this->assertSame($tour, $tour->persist(true)); + + $rc = new \ReflectionClass(tour::class); + $rcp = $rc->getProperty('id'); + $rcp->setAccessible(true); + $this->assertEquals($id, $rcp->getValue($tour)); + } + + /** + * Ensure that dirty tours are persisted. + */ + public function test_persist_dirty_existing() { + // Mock the database. + $DB = $this->mock_database(); + $DB->expects($this->once()) + ->method('update_record') + ->willReturn($this->returnSelf()) + ; + + $DB->expects($this->never()) + ->method('insert_record') + ; + + // Mock the tour. + $tour = $this->getMockBuilder(tour::class) + ->setMethods([ + 'to_record', + 'reload', + ]) + ->getMock() + ; + + $tour->expects($this->once()) + ->method('to_record') + ; + + $tour->expects($this->once()) + ->method('reload') + ; + + $rc = new \ReflectionClass(tour::class); + + $rcp = $rc->getProperty('id'); + $rcp->setAccessible(true); + $rcp->setValue($tour, 42); + + $rcp = $rc->getProperty('dirty'); + $rcp->setAccessible(true); + $rcp->setValue($tour, true); + + $this->assertSame($tour, $tour->persist()); + } + + /** + * Ensure that non-dirty, forced tours are persisted. + */ + public function test_persist_force() { + global $DB; + + // Mock the database. + $DB = $this->mock_database(); + + $DB->expects($this->once()) + ->method('update_record') + ->willReturn($this->returnSelf()) + ; + + $DB->expects($this->never()) + ->method('insert_record') + ; + + // Mock the tour. + $tour = $this->getMockBuilder(tour::class) + ->setMethods([ + 'to_record', + 'reload', + ]) + ->getMock() + ; + + $tour->expects($this->once()) + ->method('to_record') + ; + + $tour->expects($this->once()) + ->method('reload') + ; + + $rc = new \ReflectionClass(tour::class); + + $rcp = $rc->getProperty('id'); + $rcp->setAccessible(true); + $rcp->setValue($tour, 42); + + $rcp = $rc->getProperty('dirty'); + $rcp->setAccessible(true); + $rcp->setValue($tour, true); + + $this->assertSame($tour, $tour->persist(true)); + } + + /** + * Test setting config. + */ + public function test_set_config() { + $tour = new \tool_usertours\tour(); + + $tour->set_config('key', 'value'); + $tour->set_config('another', [ + 'foo' => 'bar', + ]); + + $rc = new \ReflectionClass(tour::class); + $rcp = $rc->getProperty('config'); + $rcp->setAccessible(true); + $this->assertEquals((object) [ + 'key' => 'value', + 'another' => [ + 'foo' => 'bar', + ], + ], $rcp->getValue($tour)); + } + + /** + * Test get_config with no keys provided. + */ + public function test_get_config_no_keys() { + $tour = new \tool_usertours\tour(); + + $rc = new \ReflectionClass(tour::class); + $rcp = $rc->getProperty('config'); + $rcp->setAccessible(true); + + $allvalues = (object) [ + 'some' => 'value', + 'another' => 42, + 'key' => [ + 'somethingelse', + ], + ]; + + $rcp->setValue($tour, $allvalues); + + $this->assertEquals($allvalues, $tour->get_config()); + } + + /** + * Data provider for get_config. + * + * @return array + */ + public function get_config_provider() { + $allvalues = (object) [ + 'some' => 'value', + 'another' => 42, + 'key' => [ + 'somethingelse', + ], + ]; + + return [ + 'No nitial config' => [ + null, + null, + null, + (object) [], + ], + 'All values' => [ + $allvalues, + null, + null, + $allvalues, + ], + 'Valid string value' => [ + $allvalues, + 'some', + null, + 'value', + ], + 'Valid array value' => [ + $allvalues, + 'key', + null, + ['somethingelse'], + ], + 'Invalid value' => [ + $allvalues, + 'notavalue', + null, + null, + ], + 'Configuration value' => [ + $allvalues, + 'placement', + null, + \tool_usertours\configuration::get_default_value('placement'), + ], + 'Invalid value with default' => [ + $allvalues, + 'notavalue', + 'somedefault', + 'somedefault', + ], + ]; + } + + /** + * Test get_config with valid keys provided. + * + * @dataProvider get_config_provider + * @param object $values The config values + * @param string $key The key + * @param mixed $default The default value + * @param mixed $expected The expected value + */ + public function test_get_config_valid_keys($values, $key, $default, $expected) { + $tour = new \tool_usertours\tour(); + + $rc = new \ReflectionClass(tour::class); + $rcp = $rc->getProperty('config'); + $rcp->setAccessible(true); + $rcp->setValue($tour, $values); + + $this->assertEquals($expected, $tour->get_config($key, $default)); + } + + /** + * Check that a tour which has never been persisted is removed correctly. + */ + public function test_remove_non_persisted() { + $tour = $this->getMockBuilder(tour::class) + ->setMethods([ + 'get_steps', + ]) + ->getMock() + ; + + $tour->expects($this->never()) + ->method('get_steps') + ; + + // Mock the database. + $DB = $this->mock_database(); + $DB->expects($this->never()) + ->method('delete_records') + ; + + $this->assertNull($tour->remove()); + } + + /** + * Check that a tour which has been persisted is removed correctly. + */ + public function test_remove_persisted() { + $id = rand(1, 100); + + $tour = $this->getMockBuilder(tour::class) + ->setMethods([ + 'get_steps', + ]) + ->getMock() + ; + + $rc = new \ReflectionClass(tour::class); + $rcp = $rc->getProperty('id'); + $rcp->setAccessible(true); + $rcp->setValue($tour, $id); + + $step = $this->getMockBuilder(\tool_usertours\step::class) + ->setMethods([ + 'remove', + ]) + ->getMock() + ; + + $tour->expects($this->once()) + ->method('get_steps') + ->willReturn([$step]) + ; + + // Mock the database. + $DB = $this->mock_database(); + $DB->expects($this->once()) + ->method('delete_records') + ->with($this->equalTo('tool_usertours_tours'), $this->equalTo(['id' => $id])) + ->willReturn(null) + ; + + $DB->expects($this->once()) + ->method('get_records') + ->with($this->equalTo('tool_usertours_tours'), $this->equalTo(null)) + ->willReturn([]) + ; + + $this->assertNull($tour->remove()); + } + + /** + * Teset that sortorder is reset according to sortorder with values from 0. + */ + public function test_reset_step_sortorder() { + $tour = new \tool_usertours\tour(); + + $mockdata = []; + for ($i = 4; $i >= 0; $i--) { + $id = rand($i * 10, ($i * 10) + 9); + $mockdata[] = (object) ['id' => $id]; + $expectations[] = [$this->equalTo('tool_usertours_steps'), $this->equalTo('sortorder'), 4 - $i, ['id' => $id]]; + } + + // Mock the database. + $DB = $this->mock_database(); + $DB->expects($this->once()) + ->method('get_records') + ->willReturn($mockdata) + ; + + $setfield = $DB->expects($this->exactly(5)) + ->method('set_field') + ; + call_user_func_array([$setfield, 'withConsecutive'], $expectations); + + $tour->reset_step_sortorder(); + } + + /** + * Test that a disabled tour should never be shown to users. + */ + public function test_should_show_for_user_disabled() { + $tour = new \tool_usertours\tour(); + $tour->set_enabled(false); + + $this->assertFalse($tour->should_show_for_user()); + } + + /** + * Provider for should_show_for_user. + * + * @return array + */ + public function should_show_for_user_provider() { + $time = time(); + return [ + 'Not seen by user at all' => [ + null, + null, + null, + true, + ], + 'Completed by user before majorupdatetime' => [ + $time - DAYSECS, + null, + $time, + true, + ], + 'Completed by user since majorupdatetime' => [ + $time, + null, + $time - DAYSECS, + false, + ], + 'Requested by user before current completion' => [ + $time, + $time - DAYSECS, + null, + false, + ], + 'Requested by user since completion' => [ + $time - DAYSECS, + $time, + null, + true, + ], + ]; + } + + /** + * Test that a disabled tour should never be shown to users. + * + * @dataProvider should_show_for_user_provider + * @param mixed $completiondate The user's completion date for this tour + * @param mixed $requesteddate The user's last requested date for this tour + * @param mixed $updateddate The date this tour was last updated + * @param string $expectation The expected tour key + */ + public function test_should_show_for_user($completiondate, $requesteddate, $updateddate, $expectation) { + // Uses user preferences so we must be in a user context. + $this->resetAfterTest(); + $this->setAdminUser(); + + $tour = $this->getMockBuilder(tour::class) + ->setMethods([ + 'get_id', + 'get_config', + 'is_enabled', + ]) + ->getMock() + ; + + $tour->method('is_enabled') + ->willReturn(true) + ; + + $id = rand(1, 100); + $tour->method('get_id') + ->willReturn($id) + ; + + if ($completiondate !== null) { + set_user_preference(\tool_usertours\tour::TOUR_LAST_COMPLETED_BY_USER . $id, $completiondate); + } + + if ($requesteddate !== null) { + set_user_preference(\tool_usertours\tour::TOUR_REQUESTED_BY_USER . $id, $requesteddate); + } + + if ($updateddate !== null) { + $tour->expects($this->once()) + ->method('get_config') + ->willReturn($updateddate) + ; + } + + $this->assertEquals($expectation, $tour->should_show_for_user()); + } + + /** + * Provider for get_tour_key. + * + * @return array + */ + public function get_tour_key_provider() { + $id = rand(1, 100); + $time = time(); + + return [ + 'No initial values' => [ + $id, + [null, $time], + $this->greaterThanOrEqual($time), + true, + null, + sprintf('tool_usertours_%d_%s', $id, $time), + ], + + 'Initial tour time, no user pref' => [ + $id, + [$time], + null, + false, + null, + sprintf('tool_usertours_%d_%s', $id, $time), + ], + 'Initial tour time, with user reset lower' => [ + $id, + [$time], + null, + false, + $time - DAYSECS, + sprintf('tool_usertours_%d_%s', $id, $time), + ], + 'Initial tour time, with user reset higher' => [ + $id, + [$time], + null, + false, + $time + DAYSECS, + sprintf('tool_usertours_%d_%s', $id, $time + DAYSECS), + ], + ]; + } + + /** + * Test that get_tour_key provides the anticipated unique keys. + * + * @dataProvider get_tour_key_provider + * @param int $id The tour ID + * @param array $getconfig The mocked values for get_config calls + * @param array $setconfig The mocked values for set_config calls + * @param bool $willpersist Whether a persist is expected + * @param mixed $userpref The value to set for the user preference + * @param string $expectation The expected tour key + */ + public function test_get_tour_key($id, $getconfig, $setconfig, $willpersist, $userpref, $expectation) { + // Uses user preferences so we must be in a user context. + $this->resetAfterTest(); + $this->setAdminUser(); + + $tour = $this->getMockBuilder(tour::class) + ->setMethods([ + 'get_config', + 'set_config', + 'get_id', + 'persist', + ]) + ->getMock() + ; + + if ($getconfig) { + $tour->expects($this->exactly(count($getconfig))) + ->method('get_config') + ->will(call_user_func_array([$this, 'onConsecutiveCalls'], $getconfig)) + ; + } + + if ($setconfig) { + $tour->expects($this->once()) + ->method('set_config') + ->with($this->equalTo('majorupdatetime'), $setconfig) + ->will($this->returnSelf()) + ; + } else { + $tour->expects($this->never()) + ->method('set_config') + ; + } + + if ($willpersist) { + $tour->expects($this->once()) + ->method('persist') + ; + } else { + $tour->expects($this->never()) + ->method('persist') + ; + } + + $tour->expects($this->any()) + ->method('get_id') + ->willReturn($id) + ; + + if ($userpref !== null) { + set_user_preference(\tool_usertours\tour::TOUR_REQUESTED_BY_USER . $id, $userpref); + } + + $this->assertEquals( + $expectation, + $tour->get_tour_key() + ); + } + + /** + * Ensure that the request_user_reset function sets an appropriate value for the tour. + */ + public function test_requested_user_reset() { + $tour = $this->getMockBuilder(tour::class) + ->setMethods([ + 'get_id', + ]) + ->getMock() + ; + + $id = rand(1, 100); + $time = time(); + + $tour->expects($this->once()) + ->method('get_id') + ->willReturn($id) + ; + + $tour->request_user_reset(); + + $this->assertGreaterThanOrEqual($time, get_user_preferences(\tool_usertours\tour::TOUR_REQUESTED_BY_USER . $id)); + } + + /** + * Ensure that the request_user_reset function sets an appropriate value for the tour. + */ + public function test_mark_user_completed() { + $tour = $this->getMockBuilder(tour::class) + ->setMethods([ + 'get_id', + ]) + ->getMock() + ; + + $id = rand(1, 100); + $time = time(); + + $tour->expects($this->once()) + ->method('get_id') + ->willReturn($id) + ; + + $tour->mark_user_completed(); + + $this->assertGreaterThanOrEqual($time, get_user_preferences(\tool_usertours\tour::TOUR_LAST_COMPLETED_BY_USER . $id)); + } + + /** + * Provider for the is_first_tour and is_last_tour tests. + * + * @return array + */ + public function sortorder_first_last_provider() { + $topcount = rand(10, 100); + return [ + 'Only tour => first + last' => [ + 0, + true, + 1, + true, + ], + 'First tour of many' => [ + 0, + true, + $topcount, + false, + ], + 'Last tour of many' => [ + $topcount - 1, + false, + $topcount, + true, + ], + 'Middle tour of many' => [ + 5, + false, + $topcount, + false, + ], + ]; + } + + /** + * Test the is_first_tour() function. + * + * @dataProvider sortorder_first_last_provider + * @param int $sortorder The new sort order + * @param bool $isfirst Whether this is the first tour + * @param int $total The number of tours + * @param bool $islast Whether this is the last tour + */ + public function test_is_first_tour($sortorder, $isfirst, $total, $islast) { + $tour = new \tool_usertours\tour(); + + $rc = new \ReflectionClass(tour::class); + $rcp = $rc->getProperty('sortorder'); + $rcp->setAccessible(true); + $rcp->setValue($tour, $sortorder); + + $this->assertEquals($isfirst, $tour->is_first_tour()); + } + + /** + * Test the is_last_tour() function. + * + * @dataProvider sortorder_first_last_provider + * @param int $sortorder The new sort order + * @param bool $isfirst Whether this is the first tour + * @param int $total The number of tours + * @param bool $islast Whether this is the last tour + */ + public function test_is_last_tour_calculated($sortorder, $isfirst, $total, $islast) { + $tour = new \tool_usertours\tour(); + + $rc = new \ReflectionClass(tour::class); + $rcp = $rc->getProperty('sortorder'); + $rcp->setAccessible(true); + $rcp->setValue($tour, $sortorder); + + // The total will be calculated. + $DB = $this->mock_database(); + $DB->expects($this->once()) + ->method('count_records') + ->willReturn($total) + ; + $this->assertEquals($islast, $tour->is_last_tour()); + } + + /** + * Test the is_last_tour() function. + * + * @dataProvider sortorder_first_last_provider + * @param int $sortorder The new sort order + * @param bool $isfirst Whether this is the first tour + * @param int $total The number of tours + * @param bool $islast Whether this is the last tour + */ + public function test_is_last_tour_provided($sortorder, $isfirst, $total, $islast) { + $tour = new \tool_usertours\tour(); + + $rc = new \ReflectionClass(tour::class); + $rcp = $rc->getProperty('sortorder'); + $rcp->setAccessible(true); + $rcp->setValue($tour, $sortorder); + + // The total is provided. + // No DB calls expected. + $DB = $this->mock_database(); + $DB->expects($this->never()) + ->method('count_records') + ->willReturn(0) + ; + $this->assertEquals($islast, $tour->is_last_tour($total)); + } + + /** + * Data provider for the get_filter_values tests. + * + * @return array + */ + public function get_filter_values_provider() { + $cheese = ['cheddar', 'boursin', 'mozzarella']; + $horses = ['coolie', 'dakota', 'leo', 'twiggy']; + return [ + 'No config' => [ + [], + 'cheese', + [], + ], + 'Some config for another filter' => [ + [ + 'horses' => $horses, + ], + 'cheese', + [], + ], + 'Some config for this filter' => [ + [ + 'horses' => $horses, + ], + 'horses', + $horses, + ], + 'Some config for several filters' => [ + [ + 'horses' => $horses, + 'cheese' => $cheese + ], + 'horses', + $horses, + ], + ]; + } + + /** + * Tests for the get_filter_values function. + * + * @dataProvider get_filter_values_provider + * @param array $fullconfig The config value being tested + * @param string $filtername The name of the filter being tested + * @param array $expectedvalues The expected result + */ + public function test_get_filter_values($fullconfig, $filtername, $expectedvalues) { + $tour = $this->getMockBuilder(tour::class) + ->setMethods(['get_config']) + ->getMock(); + + $tour->expects($this->once()) + ->method('get_config') + ->will($this->returnValue($fullconfig)); + + $this->assertEquals($expectedvalues, $tour->get_filter_values($filtername)); + } + + /** + * Data provider for set_filter_values tests. + * + * @return array + */ + public function set_filter_values_provider() { + $cheese = ['cheddar', 'boursin', 'mozzarella']; + $horses = ['coolie', 'dakota', 'leo', 'twiggy']; + + return [ + 'No initial value' => [ + [], + 'cheese', + $cheese, + ['cheese' => $cheese], + ], + 'Existing filter merged' => [ + ['horses' => $horses], + 'cheese', + $cheese, + ['horses' => $horses, 'cheese' => $cheese], + ], + 'Existing filter updated' => [ + ['cheese' => $cheese], + 'cheese', + ['cheddar'], + ['cheese' => ['cheddar']], + ], + 'Existing filter updated with merge' => [ + ['horses' => $horses, 'cheese' => $cheese], + 'cheese', + ['cheddar'], + ['horses' => $horses, 'cheese' => ['cheddar']], + ], + ]; + } + + /** + * Base tests for set_filter_values. + * + * @dataProvider set_filter_values_provider + * @param array $currentvalues The current value + * @param string $filtername The name of the filter to add to + * @param array $newvalues The new values to store + * @param array $expectedvalues The combined values + */ + public function test_set_filter_values_merge($currentvalues, $filtername, $newvalues, $expectedvalues) { + $tour = $this->getMockBuilder(tour::class) + ->setMethods(['get_config', 'set_config']) + ->getMock(); + + $tour->expects($this->once()) + ->method('get_config') + ->will($this->returnValue($currentvalues)); + + $tour->expects($this->once()) + ->method('set_config') + ->with( + $this->equalTo('filtervalues'), + $this->equalTo($expectedvalues) + ); + + $tour->set_filter_values($filtername, $newvalues); + } +} diff --git a/admin/tool/usertours/thirdpartylibs.xml b/admin/tool/usertours/thirdpartylibs.xml new file mode 100644 index 00000000000..99e6120647b --- /dev/null +++ b/admin/tool/usertours/thirdpartylibs.xml @@ -0,0 +1,17 @@ + + + + amd/src/tour.js + Flexitour + GPLv3 + 0.9.6 + 3 + + + amd/src/popper.js + Popper.js + MIT + v0.6.4 + + + diff --git a/admin/tool/usertours/version.php b/admin/tool/usertours/version.php new file mode 100644 index 00000000000..827b68ebc16 --- /dev/null +++ b/admin/tool/usertours/version.php @@ -0,0 +1,29 @@ +. + +/** + * Version information. + * + * @package tool_usertours + * @copyright 2016 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +$plugin->version = 2016101800; // The current module version (Date: YYYYMMDDXX). +$plugin->requires = 2016052300; // Requires this Moodle version. +$plugin->component = 'tool_usertours'; // Full name of the plugin (used for diagnostics). diff --git a/lib/classes/plugin_manager.php b/lib/classes/plugin_manager.php index a24bacf65fc..047ad738b90 100644 --- a/lib/classes/plugin_manager.php +++ b/lib/classes/plugin_manager.php @@ -1908,7 +1908,7 @@ class core_plugin_manager { 'dbtransfer', 'filetypes', 'generator', 'health', 'innodb', 'installaddon', 'langimport', 'log', 'lp', 'lpimportcsv', 'lpmigrate', 'messageinbound', 'mobile', 'multilangupgrade', 'monitor', 'phpunit', 'profiling', 'recyclebin', 'replace', 'spamcleaner', 'task', 'templatelibrary', - 'unittest', 'uploadcourse', 'uploaduser', 'unsuproles', 'xmldb' + 'unittest', 'uploadcourse', 'uploaduser', 'unsuproles', 'usertours', 'xmldb' ), 'webservice' => array( diff --git a/theme/boost/scss/moodle.scss b/theme/boost/scss/moodle.scss index 07694781dd5..9080050c6a0 100644 --- a/theme/boost/scss/moodle.scss +++ b/theme/boost/scss/moodle.scss @@ -38,3 +38,4 @@ $breadcrumb-divider-rtl: "◀" !default; @import "moodle/expendable"; @import "moodle/sticky-footer"; @import "moodle/popover-region"; +@import "moodle/tool_usertours"; diff --git a/theme/boost/scss/moodle/modules.scss b/theme/boost/scss/moodle/modules.scss index 08008330250..c2f461de3c1 100644 --- a/theme/boost/scss/moodle/modules.scss +++ b/theme/boost/scss/moodle/modules.scss @@ -488,7 +488,7 @@ div#dock { } } -&.userloggedinas .usermenu .userbutton .avatars { +.userloggedinas .usermenu .userbutton .avatars { .avatar { &.current { left: 16px; diff --git a/theme/boost/scss/moodle/tool_usertours.scss b/theme/boost/scss/moodle/tool_usertours.scss new file mode 100644 index 00000000000..b2ff962f1a0 --- /dev/null +++ b/theme/boost/scss/moodle/tool_usertours.scss @@ -0,0 +1,105 @@ +/** + * Tour step must sit above all other UI components. + * The backdrop is the lowest point in the tour. + * Everything else is in the container, and the target background should be at the same z-index. + * ----- moodle + * ---- step backdrop + * --- step container + * --- step target background + */ +$flexitour-base-zindex: 1040; + +// The backdrop is the backdrop used in 'modal' step display. +div[data-flexitour="backdrop"] { + background-color: #{$modal-backdrop-bg}; + opacity: #{$modal-backdrop-opacity}; + + // The backdrop needs to have a lower z-index than everything else in the tour, but higher than everything else in Moodle. + z-index: #{$flexitour-base-zindex}; +} + +// The step-background is used to highlight the region targetted in the step. +div[data-flexitour="step-background-fader"], +div[data-flexitour="step-background"] { + @include border-radius($border-radius-lg); + + // The step container, and the target background should be at the same z-index. + z-index: #{$flexitour-base-zindex} + 1; +} + +span[data-flexitour="container"], +div[data-flexitour="step-background-fader"], +[data-flexitour="step-backdrop"] > td, +[data-flexitour="step-backdrop"] { + // The step container, and the target background should be at the same z-index. + z-index: #{$flexitour-base-zindex} + 2; +} + +span[data-flexitour="container"] { + .modal-dialog { + /** + * Remove all margins to: + * 1) ensure that the arrow touches the target; and + * 2) ensure that the focus border touches the modal. + */ + margin: 0; + } + + div[data-role="arrow"] { + border-width: #{$popover-arrow-outer-width}; + } + + div[data-role="arrow"], + div[data-role="arrow"]:after { + position: absolute; + display: block; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + border-width: #{$popover-arrow-width}; + } + + // We need to know the opposite sides for arrow placement. + $opposites: + ( + top: bottom, + bottom: top, + left: right, + right: left, + ); + + // These are the next leading side and allow for placement half-way along the step dialogue. + $sides: + ( + top: left, + bottom: left, + left: top, + right: top, + ); + + @each $direction in map-keys($opposites) { + $opposite: map_get($opposites, $direction); + $side: map_get($sides, $direction); + $oppositeside: map_get($opposites, $side); + + &[x-placement="#{$direction}"], + &[x-placement="#{$direction}-start"] { + margin-#{$opposite}: #{$popover-arrow-width}; + div[data-role="arrow"] { + #{$opposite}: -#{$popover-arrow-outer-width}; + #{$side}: 50%; + margin-#{$side}: -#{$popover-arrow-outer-width}; + border-#{$opposite}-width: 0; + border-#{$direction}-color: #{$popover-arrow-outer-color}; + } + div[data-role="arrow"]:after { + #{$opposite}: 1px; + margin-#{$side}: -#{$popover-arrow-width}; + content: " "; + border-#{$opposite}-width: 0; + border-#{$direction}-color: #{$popover-arrow-color}; + } + } + } +} diff --git a/theme/boost/templates/tool_usertours/tourstep.mustache b/theme/boost/templates/tool_usertours/tourstep.mustache new file mode 100644 index 00000000000..7c9da826c63 --- /dev/null +++ b/theme/boost/templates/tool_usertours/tourstep.mustache @@ -0,0 +1,65 @@ +{{! + 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 . +}} +{{! + @template tool_usertours/tourstep + + Template used as a template to render step bubbles with their content + in a user tour. + + Classes required for JS: + * arrow + * popover-title + * popover-content + * popover-navigation + * popover-title + + Data attributes required for JS: + * data-role=prev + * data-role=next + * data-role=pause-resume + * data-pause-text + * data-resume-text + * data-role=end + * data-placeholder=body + * data-placeholder=title + + Context variables required for this template: + * None + + Example context (json): + { + } + +}} + diff --git a/theme/bootstrapbase/less/moodle.less b/theme/bootstrapbase/less/moodle.less index 8515c2b8179..5c6c55ebdf2 100644 --- a/theme/bootstrapbase/less/moodle.less +++ b/theme/bootstrapbase/less/moodle.less @@ -60,6 +60,7 @@ body { @import "moodle/buttons"; @import "moodle/grade"; @import "moodle/dock"; +@import "moodle/tool_usertours"; // Styles for mustache templates. @import "moodle/templates"; diff --git a/theme/bootstrapbase/less/moodle/tool_usertours.less b/theme/bootstrapbase/less/moodle/tool_usertours.less new file mode 100644 index 00000000000..debfde9886f --- /dev/null +++ b/theme/bootstrapbase/less/moodle/tool_usertours.less @@ -0,0 +1,146 @@ +/** + * Tour step must sit above all other UI components. + * The backdrop is the lowest point in the tour. + * Everything else is in the container, and the target background should be at the same z-index. + * ----- moodle + * ---- step backdrop + * --- step container + * --- step target background + */ +@flexitour-base-zindex: 4030; + +// The backdrop is the backdrop used in 'modal' step display. +div[data-flexitour="backdrop"] { + .modal-backdrop(); + + // The backdrop needs to have a lower z-index than everything else in the tour, but higher than everything else in Moodle. + z-index: @flexitour-base-zindex; +} + +// The step-background is used to highlight the region targetted in the step. +div[data-flexitour="step-background-fader"], +div[data-flexitour="step-background"] { + .border-radius(6px); + + // The step target background should be above the base. + z-index: (@flexitour-base-zindex + 1); +} + +span[data-flexitour="container"], +div[data-flexitour="step-background-fader"], +[data-flexitour="step-backdrop"] > td, +[data-flexitour="step-backdrop"] { + // The step target background should be above the base. + z-index: (@flexitour-base-zindex + 2); +} + +span[data-flexitour="container"] { + div[data-role="flexitour-step"] { + background-color: #fff; + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, .2); + box-shadow: 0 5px 10px rgba(0, 0, 0, .2); + + > div { + // When targetting some regions, the selectors override the modal ones. + display: block; + margin: 0; + } + } + + .modal { + position: static; + margin-left: 0; + } + + div[data-role="arrow"] { + border-width: @popoverArrowOuterWidth; + } + + div[data-role="arrow"]:after { + content: " "; + } + + div[data-role="arrow"], + div[data-role="arrow"]:after { + position: absolute; + display: block; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + border-width: @popoverArrowWidth; + } + + &[x-placement="top"], + &[x-placement="top-start"] { + margin-bottom: @popoverArrowWidth; + div[data-role="arrow"] { + bottom: -@popoverArrowOuterWidth; + left: 50%; + margin-left: -@popoverArrowOuterWidth; + border-bottom-width: 0; + border-top-color: @popoverArrowOuterColor; + } + div[data-role="arrow"]:after { + bottom: 1px; + margin-left: -@popoverArrowWidth; + border-bottom-width: 0; + border-top-color: @popoverArrowColor; + } + } + + &[x-placement="bottom"], + &[x-placement="bottom-start"] { + margin-top: @popoverArrowWidth; + div[data-role="arrow"] { + top: -@popoverArrowOuterWidth; + left: 50%; + margin-left: -@popoverArrowOuterWidth; + border-top-width: 0; + border-bottom-color: @popoverArrowOuterColor; + } + div[data-role="arrow"]:after { + top: 1px; + margin-left: -@popoverArrowWidth; + border-top-width: 0; + border-bottom-color: @popoverArrowColor; + } + } + + &[x-placement="left"], + &[x-placement="left-start"] { + margin-right: @popoverArrowWidth; + div[data-role="arrow"] { + right: -@popoverArrowOuterWidth; + top: 50%; + margin-top: -@popoverArrowOuterWidth; + border-right-width: 0; + border-left-color: @popoverArrowOuterColor; + } + div[data-role="arrow"]:after { + right: 1px; + margin-top: -@popoverArrowWidth; + border-right-width: 0; + border-left-color: @popoverArrowColor; + } + } + + &[x-placement="right"], + &[x-placement="right-start"] { + margin-left: @popoverArrowWidth; + div[data-role="arrow"] { + left: -@popoverArrowOuterWidth; + top: 50%; + margin-top: -@popoverArrowOuterWidth; + border-left-width: 0; + border-right-color: @popoverArrowOuterColor; + } + div[data-role="arrow"]:after { + left: 1px; + margin-top: -@popoverArrowWidth; + border-left-width: 0; + border-right-color: @popoverArrowColor; + } + } +} diff --git a/theme/bootstrapbase/style/moodle.css b/theme/bootstrapbase/style/moodle.css index 76a4b080f7e..e52627a7c67 100644 --- a/theme/bootstrapbase/style/moodle.css +++ b/theme/bootstrapbase/style/moodle.css @@ -16632,6 +16632,152 @@ body.has_dock div#dock { padding-left: 65px; } } +/** + * Tour step must sit above all other UI components. + * The backdrop is the lowest point in the tour. + * Everything else is in the container, and the target background should be at the same z-index. + * ----- moodle + * ---- step backdrop + * --- step container + * --- step target background + */ +div[data-flexitour="backdrop"] { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 1040; + background-color: #000; + opacity: 0.8; + filter: alpha(opacity=80); + z-index: 4049; + background-color: #aaa; + opacity: 0.4; + z-index: 4030; +} +div[data-flexitour="backdrop"].fade { + opacity: 0; +} +div[data-flexitour="step-background-fader"], +div[data-flexitour="step-background"] { + -webkit-border-radius: 6px; + -moz-border-radius: 6px; + border-radius: 6px; + z-index: 4031; +} +span[data-flexitour="container"], +div[data-flexitour="step-background-fader"], +[data-flexitour="step-backdrop"] > td, +[data-flexitour="step-backdrop"] { + z-index: 4032; +} +span[data-flexitour="container"] div[data-role="flexitour-step"] { + background-color: #fff; + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.2); + box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); +} +span[data-flexitour="container"] div[data-role="flexitour-step"] > div { + display: block; + margin: 0; +} +span[data-flexitour="container"] .modal { + position: static; + margin-left: 0; +} +span[data-flexitour="container"] div[data-role="arrow"] { + border-width: 11px; +} +span[data-flexitour="container"] div[data-role="arrow"]:after { + content: " "; +} +span[data-flexitour="container"] div[data-role="arrow"], +span[data-flexitour="container"] div[data-role="arrow"]:after { + position: absolute; + display: block; + width: 0; + height: 0; + border-color: transparent; + border-style: solid; + border-width: 10px; +} +span[data-flexitour="container"][x-placement="top"], +span[data-flexitour="container"][x-placement="top-start"] { + margin-bottom: 10px; +} +span[data-flexitour="container"][x-placement="top"] div[data-role="arrow"], +span[data-flexitour="container"][x-placement="top-start"] div[data-role="arrow"] { + bottom: -11px; + left: 50%; + margin-left: -11px; + border-bottom-width: 0; + border-top-color: rgba(0, 0, 0, 0.25); +} +span[data-flexitour="container"][x-placement="top"] div[data-role="arrow"]:after, +span[data-flexitour="container"][x-placement="top-start"] div[data-role="arrow"]:after { + bottom: 1px; + margin-left: -10px; + border-bottom-width: 0; + border-top-color: #fff; +} +span[data-flexitour="container"][x-placement="bottom"], +span[data-flexitour="container"][x-placement="bottom-start"] { + margin-top: 10px; +} +span[data-flexitour="container"][x-placement="bottom"] div[data-role="arrow"], +span[data-flexitour="container"][x-placement="bottom-start"] div[data-role="arrow"] { + top: -11px; + left: 50%; + margin-left: -11px; + border-top-width: 0; + border-bottom-color: rgba(0, 0, 0, 0.25); +} +span[data-flexitour="container"][x-placement="bottom"] div[data-role="arrow"]:after, +span[data-flexitour="container"][x-placement="bottom-start"] div[data-role="arrow"]:after { + top: 1px; + margin-left: -10px; + border-top-width: 0; + border-bottom-color: #fff; +} +span[data-flexitour="container"][x-placement="left"], +span[data-flexitour="container"][x-placement="left-start"] { + margin-right: 10px; +} +span[data-flexitour="container"][x-placement="left"] div[data-role="arrow"], +span[data-flexitour="container"][x-placement="left-start"] div[data-role="arrow"] { + right: -11px; + top: 50%; + margin-top: -11px; + border-right-width: 0; + border-left-color: rgba(0, 0, 0, 0.25); +} +span[data-flexitour="container"][x-placement="left"] div[data-role="arrow"]:after, +span[data-flexitour="container"][x-placement="left-start"] div[data-role="arrow"]:after { + right: 1px; + margin-top: -10px; + border-right-width: 0; + border-left-color: #fff; +} +span[data-flexitour="container"][x-placement="right"], +span[data-flexitour="container"][x-placement="right-start"] { + margin-left: 10px; +} +span[data-flexitour="container"][x-placement="right"] div[data-role="arrow"], +span[data-flexitour="container"][x-placement="right-start"] div[data-role="arrow"] { + left: -11px; + top: 50%; + margin-top: -11px; + border-left-width: 0; + border-right-color: rgba(0, 0, 0, 0.25); +} +span[data-flexitour="container"][x-placement="right"] div[data-role="arrow"]:after, +span[data-flexitour="container"][x-placement="right-start"] div[data-role="arrow"]:after { + left: 1px; + margin-top: -10px; + border-left-width: 0; + border-right-color: #fff; +} /* Styling for mustache templates */ /* stylelint-disable declaration-block-no-duplicate-properties, no-browser-hacks */ /* stylelint-enable */