Completion based off slides viewed, excluding those slides not required.

I'm working in Articulate Storyline 2.

So, essentially I want to create a course in which completion is based off slides viewed, however there are many slides which do not need to be viewed in order to pass the course. You might say if you have 25 slides and want 20 to be required set the completion requirements to: Slides Viewed 20 out of 25.

But what if you have 100 slides and 5 are required. (Not exactly my situation but the example draws my issue to light.

So far the only solution I can think of is to have a boolean variable for every required slide (this is like 50 - 150 slides in most of our courses), and when the user goes to the exit confirmation slide jump to the slightly different exit (complete) confirmation slide if all of these variables = true.

This is by no means an elegant solution, but it accomplishes my desired result. Due to the requirements from the customer it might just be my only solution, can anyone think of a cleaner way of accomplishing this?

I've put in a feature request for a "Required Slide" checkbox, however we cannot adjust our deadlines to wait for a feature enhancement that may never come, so I need to resolve this now, and if I can't come up with an elegant solution quickly, I need to get making variables...

12 Replies
Matthew Bibby

Hey Matt, I use a similar approach to track if learners have engaged with key activities and it works really well. When the learner reaches the end of the course they are taken to the completion slide if they have engaged with all of the key activities. Alternatively, they go to another slide which lists the required activities with an indicator (tick or cross) that shows which ones they need to visit before the module will be marked as complete.

I've set it up so that they can jump directly to that location and then they will be brought back to the end of the course again (where the same logic runs in case they have missed multiple key activities). It took a little bit to set up, but is a great solution and my client loves it.

Another approach might be to use a Freeform Pick One question for each of the required slides (assuming you are using custom navigation) where clicking the Next button on that slide will be the 'correct' answer. Then you could set it up so that they needed to score 100% to pass the module. The problem with this approach is that the learner may not know what they have to do to pass the module.

Matthew Paquette

So I haven't flushed it out yet but i have the basics of a solution forming that will dynamically work for all of our courses using javascript. We have a totalPages, and currentPage variable. So I made a new variable completionString. Since there are no array variables in storyline this is where im storing my array, as a string.

When the currentPage variable changes I'm executing JavaScript that will take completionString, convert it to an array by splitting on commas, then push currentPage into that array if it is not in there already, sort the array, and then save the array as string back to completionString. I'm thinking the script will also check the length of the array and if it's equal to totalPages then change a boolean variable to true to mark the course complete.

Then on my exit page if that variable is true it redirects to a page that marks the complete.

Matthew Paquette

Ok so it works!!!

Here is the code I used.

//Create Reference to Storyline
var player = GetPlayer();

//Get Variables from Storyline
var jsSlide = player.GetVar("currentPage");
var jsCompletion = player.GetVar("completionString");

//Convert stored completion to an array
var cArray = [];
if(jsCompletion!=""){
cArray = jsCompletion.split(",");
}

if(cArray.indexOf(jsSlide.toString()) == -1) {
cArray.push(jsSlide);
cArray.sort(function(a,b){return a-b});
}

//If all pages have been viewed then flag the course complete
if(cArray.length == player.GetVar("totalPages")){
player.SetVar("courseComplete") = true;
SetStatus("completed")
}

player.SetVar("completionString", cArray.toString());

Basically totalPages is the number of pages the user actually needs to visit, in my course these are the 'content' pages.

All of the content pages contain a trigger on timeline start that updates the currentPage variable.

Whenever this variable changes the script above is called, the currentPage and completionString (default value is blank), variables are captured.

Then if the completion variable has an array already saved to it (as a string since storyline cannot accept arrays) the string is split into an array.

It then checks if the current slides number is already present in the array, if not it adds it (and then sorts the array, which is not necessary but helped when debugging).

Lastly the script checks if the length of the array is equal to the number of total pages, and when this occurs it changes my boolean variable to true, so I can show the "You have completed this course test" instead of the "Warning you have not completed this course" info, it also immediately flags the course as complete so even if they alt+f4 at this point the lms will receive their completion, theoretically (haven't sent this through the testlab yet).

Matthew Paquette

Well we went to platform testing, and found some issues, I've made some corrections, shown below but we still have one glaring issue. It works fine in the html5 output, but our flash output is not submitting the status when SetStatus("complete") is called, and the script will not run any code that appears below that line. If I set view all the slides (included non-content slides) the course will be marked as "Passed" even though its set to "Complete/Incomplete" when all slides have been viewed.

//Create Reference to Storyline
var player = GetPlayer();

//Get Variables from Storyline
var jsSlide = player.GetVar("currentPage");
var jsCompletion = player.GetVar("completionString");

//Convert stored completion to an array
var cArray = [];
if(jsCompletion!=""){
cArray = jsCompletion.split(",");
}

//IF IE8, create the indexOf function
if (!Array.prototype.indexOf)
{
Array.prototype.indexOf = function(elt /*, from*/)
{
var len = this.length >>> 0;

var from = Number(arguments[1]) || 0;
from = (from < 0)
? Math.ceil(from)
: Math.floor(from);
if (from < 0)
from += len;

for (; from < len; from++)
{
if (from in this &&
this[from] === elt)
return from;
}
return -1;
};
}

if(cArray.indexOf(jsSlide.toString()) == -1) {
cArray.push(jsSlide);
cArray.sort(function(a,b){return a-b});
}

//If all pages have been viewed then flag the course complete
if(cArray.length == player.GetVar("totalPages")){
console.log("Course Complete!!!");
player.SetVar("courseComplete", true);
SetStatus("completed")
}

console.log(cArray.length+" == "+player.GetVar("totalPages"));

player.SetVar("completionString", cArray.toString());

console.log("EoS!");
Matthew Paquette

So, something every web programmer should know, come to find out, IE8 errors out on console.log, unless you have the console open. The script above works in IE10 but for IE8 we needed to remove the console calls, although the course still does some weird stuff at the end, sending a "passed" status, instead of complete as it does in IE10, and then immediately sending a "incomplete" status after that making the course incomplete.

Steve Flowers

This is a good method. Built something similar last year:

On each master slide (works but creates a function for each slide) on a delay of about half a second. On each slide, I set the variable cpage to the index of the slide (1, 2, 3) . Where you're pushing into the array, I'm just flipping the address. Using this method, you could draw out by slide address and calculate a string or sum.

var player=GetPlayer();
var masteryArray=player.GetVar("completeArray").split(",");
var cIndex=player.GetVar("cpage");
masteryArray[Number(cIndex)]=1;
var collapsedArray=masteryArray.toString();
player.SetVar("completeArray",collapsedArray);
for (var i = 0, sum = 0; i < masteryArray.length; sum += Number(masteryArray[i++]));
player.SetVar("totalComplete",Number(sum));

I used it to flip on an indicator and calculate both total completion and show an indicator when the conditions of completion had been reached. I also played with a progress indicator - each shape on the slide contained this script:

var player=GetPlayer();
var currentItem=5; // change by item
var mArray=player.GetVar("completeArray").split(",");
player.SetVar("isVisited","False");
if(mArray[currentItem]==1){
player.SetVar("isVisited","True");
}

In the same trigger series, the shape checks the value of isVisited and toggles the shape state based on that. Since everything executes in order, the shapes and the triggers are nearly identical.

There is a serious downside to adding these to the master slide. The script for the master slide is replicated N times (where N is the value of the total slides using that master) in the user.js file. Icky efficiency. 

Fun times.

Matthew Paquette

Yup as it turns out I had changed SetStatus("completed") to SetStatus("passed") while trying to debug before a co-worker pointed out my console.log was what was killing it, IE10 didnt seem to mind and still went with completed since the publish settings were set to completed/incomplete, however IE8 did not like the mismatch, so it acted strangely, I restructured the code a little bit so that SetStatus is the last thing it does, just to make sure it pushes that array variable no matter what.

I also added a new scene with a single slide that is inaccessible so storyline never sends its own completed status, at this time we are re-doing our entire paltform test to make sure we didnt break anything else while fixing this but so far this final script is testing well...

//Create Reference to Storyline
var player = GetPlayer();

//Get Variables from Storyline
var jsSlide = player.GetVar("currentPage");
var jsCompletion = player.GetVar("completionString");

//Convert stored completion to an array
var cArray = [];
if(jsCompletion!=""){
cArray = jsCompletion.split(",");
}

//IF IE8, create the indexOf function
if (!Array.prototype.indexOf)
{
Array.prototype.indexOf = function(elt /*, from*/)
{
var len = this.length >>> 0;

var from = Number(arguments[1]) || 0;
from = (from < 0)
? Math.ceil(from)
: Math.floor(from);
if (from < 0)
from += len;

for (; from < len; from++)
{
if (from in this &&
this[from] === elt)
return from;
}
return -1;
};
}

//If current page is not in array, then add it
if(cArray.indexOf(jsSlide.toString()) == -1) {
cArray.push(jsSlide);
cArray.sort(function(a,b){return a-b});
}

//update the SL variable with our array data for next time
player.SetVar("completionString", cArray.toString());

//If all pages have been viewed then flag the course complete
if(cArray.length == player.GetVar("totalPages")){
player.SetVar("courseComplete", true);
SetStatus("completed")
}
Matthew Paquette

I noticed that as well, that adding a script to the slide master makes the script duplicated for the number of slides, and that there is a switch statement to determine which one of the cloned scripts to run, I'm fine with this as ultimately the functionality i get works, but is there a better practice for a script that needs to run on each page other than the master slide?

Steve Flowers

The event needs to be triggered somehow. I've added some JS files using a Web Object, referenced these and added the JS to the header. Then called these with a single function call. At least this way it's a lot less bulk in the user.js file.

init();

Here's the function and script I use to load scripts. There's more here than necessary but I wanted it to be extendable for a bunch of scripts. I'll use this to load Jquery at run time as well as stuff like SoundManager.

this.oLocation="story_content/WebObjects/66vjsN26Fjn/";
function add_script(scriptURL,oID) {
var scriptEl = document.createElement("script");
var head=document.getElementsByTagName('head')[0];
scriptEl.type = "text/javascript";
scriptEl.src = scriptURL;
scriptEl.id=oID;
head.appendChild(scriptEl);
}
if(document.getElementById('libraryload')==null){
add_script(oLocation+"script/yourlibrary-jsmin.js","libraryload");
}

There are downsides to this as well. The function needs to be loaded to call the external dependencies. The only way to reliably do this without post-publish surgery is by adding it to the master slide. Catch-22:) I usually save myself overall by a compressed version of similar to the dynamic load above. 

Editing the published output would get it as well. Long ago, I put in a feature request to have something like custom scripts or custom header / custom body close. Time to wake that one up again and submit another for the master slide function dupes.