Wednesday, 5 June 2019

Web Tutorial: The Liverpool Quiz (Part 2/3)

In this part, we'll be mainly dealing application flow - from beginning, to questions, to results. Part of flow control will be handled with CSS.

Selective Invisibility

The main principle here is that this is a Single Page Application. That means that everything is invisible until it needs to be visible. So here, set the pnlMain, pnlQuestions and pnlResults classes to have the display property set to none. Refresh, and everything on your page should disappear!

css\styles.css
.pnlMain, .pnlQuestions, .pnlResults
{
    display: none;
}


Next, set this. If the div is styled using pnlMain and main, display is set to block. Same for pnlQuestions and questions, and pnlResults and results.

css\styles.css
.pnlMain, .pnlQuestions, .pnlResults
{
    display: none;
}

.pnlMain.main, .pnlQuestions.questions, .pnlResults.results
{
    display: block;
}


Then do this in the HTML. This ensures that each of these divs is also styled using whatever stage this app is in right now - "main", "questions" or "results". This is determined using the getStage() function.

index.html
            <div class="pnlMain {{getStage()}}">
                <div class="content">
                    <h1>Welcome to the Liverpool FC Quiz!</h1>
                    <p>Let's see how well you know your club.</p>
                </div>
               
                <div class="buttons">
                    <input type="button" id="btnBegin" class="btn" value="Begin">
                </div>
            </div>

            <div class="pnlQuestions {{getStage()}}">
                <div ng-repeat="question in questions">
                    <div class="content">
                        <div class="question">
                            <div class="questionText">
                                {{question.text}}
                            </div>

                            <div class="questionProgress">
                               
                            </div>   
                        </div>

                        <br style="clear:both">

                        <div class="answers">
                            <div class="option" ng-repeat="option in question.options">
                                <div class="selector">

                                </div>

                                <div class="text">
                                    <div class="optionText">{{option.text}}</div>
                                    <div class="optionExplanation">{{question.explanation}}</div>
                                </div>
                            </div>
                        </div>

                        <br style="clear:both">
                    </div>
                </div>

                <div class="buttons">
                    <input type="button" id="btnNext" class="btn" value="">
                    <input type="button" id="btnQuit" class="btn" value="Quit">
                </div>
            </div>

            <div class="pnlResults {{getStage()}}">
                <div class="content">
                    <h1>{{secondsRemainingMessage}}</h1>
                    <p>Your score is <b>{{result}}</b> out of <b>{{possible}}</b>.</p>
                    <p>{{resultsMessage}}</p>
                </div>

                <div class="buttons">
                    <input type="button" id="btnRestart" class="btn" value="Restart"
                </div>
            </div>


And then we write the getStage() function in the JavaScript.

js\main.js
    $scope.initQuiz =
    function()
    {   
        $scope.result = 0;
        $scope.currentQuestion = -1;
        $scope.secondsRemaining = 60;
        $scope.proceed = "Next";
        $scope.timer = $interval.cancel($scope.timer);
        $scope.timer = null;
    };

    $scope.getStage =
    function ()
    {

    };


If currentQuestion is -1, the default value, that means that we haven't begun the quiz, so the stage is "main".

js\main.js
    $scope.getStage =
    function ()
    {
        if ($scope.currentQuestion == -1) return "main";
    };


If currentQuestion equals the length of the questions array (because iterating through the questions causes currentQuestion to increment, something we'll cover later), the stage is "results". Naturally, if you're done answering all the questions, you'll see results, right?

Editor's Note: Some may question (very naturally) why I use the shorthand If block for the first case, and the curly-braced version for the second case. That's because for the second case, I'm planning to add more stuff later.

js\main.js
    $scope.getStage =
    function ()
    {
        if ($scope.currentQuestion == -1) return "main";

        if ($scope.currentQuestion == $scope.questions.length)
        {
            return "results";
        }
    };


And for everything else, the stage is "questions".

js\main.js
    $scope.getStage =
    function ()
    {
        if ($scope.currentQuestion == -1) return "main";

        if ($scope.currentQuestion == $scope.questions.length)
        {
            return "results";
        }

        return "questions";
    };


Since currentQuestion is -1, your stage is "main", and the page reflects that! All the other sections are invisible at this point because we set that in the CSS.


Let's program a button!

What we will do here is make the "Begin" button move on to the questions section. So set the behavior by using AngularJS's ng-click attribute. It will fire off the function nextQuestion().

index.html
                <div class="buttons">
                    <input type="button" id="btnBegin" class="btn" value="Begin" ng-click="nextQuestion()">
                </div>


Create the nextQuestion() function.

js\main.js
    $scope.initQuiz =
    function()
    {   
        $scope.result = 0;
        $scope.currentQuestion = -1;
        $scope.secondsRemaining = 60;
        $scope.proceed = "Next";
        $scope.timer = $interval.cancel($scope.timer);
        $scope.timer = null;
    };

    $scope.nextQuestion =
    function ()
    {

    };

    $scope.getStage =
    function ()
    {
        if ($scope.currentQuestion == -1) return "main";

        if ($scope.currentQuestion == $scope.questions.length)
        {
            return "results";
        }

        return "questions";
    };


Here, nextQuestion() increments the value of currentQuestion by 1. It's that simple... for now.

js\main.js
    $scope.nextQuestion =
    function ()
    {
        $scope.currentQuestion ++;   
    };


Refresh. Click the "Begin" button. This is what you should get - the page moves to questions! Of course, we don't want all questions to be visible at the same damn time, so...


...in the CSS, hide any elements styled using content within pnlQuestions.

css\styles.css
.pnlMain, .pnlQuestions, .pnlResults
{
    display: none;
}

.pnlMain.main, .pnlQuestions.questions, .pnlResults.results
{
    display: block;
}

.pnlQuestions .content
{
    display: none;
}


Refresh. Click the "Begin" button. Now that the questions are all hidden, you'll be left with the blank button and the "Quit" button. Don't worry, all this is going exactly to plan.


Now, un-hide stuff if it's styled using both content and current, within pnlQuestions.

css\styles.css
.pnlQuestions .content
{
    display: none;
}

.pnlQuestions .content.current
{
    display: block;
}


Remember this repeated block? Well, in the div styled as content, add this. Pass in $index as an argument for the isCurrentQuestion() function. $index is the position of the current element of questions.

index.html
            <div class="pnlQuestions {{getStage()}}">
                <div ng-repeat="question in questions">
                    <div class="content {{isCurrentQuestion($index)}}">
                        <div class="question">
                            <div class="questionText">
                                {{question.text}}
                            </div>

                            <div class="questionProgress">
                               
                            </div>   
                        </div>

                        <br style="clear:both">

                        <div class="answers">
                            <div class="option" ng-repeat="option in question.options">
                                <div class="selector">

                                </div>

                                <div class="text">
                                    <div class="optionText">{{option.text}}</div>
                                    <div class="optionExplanation">{{question.explanation}}</div>
                                </div>
                            </div>
                        </div>

                        <br style="clear:both">
                    </div>
                </div>

                <div class="buttons">
                    <input type="button" id="btnNext" class="btn" value="">
                    <input type="button" id="btnQuit" class="btn" value="Quit">
                </div>
            </div>


You guessed it, we'll be writing the isCurrentQuestion() function next. By default, return an empty string.

js\main.js
    $scope.initQuiz =
    function()
    {   
        $scope.result = 0;
        $scope.currentQuestion = -1;
        $scope.secondsRemaining = 60;
        $scope.proceed = "Next";
        $scope.timer = $interval.cancel($scope.timer);
        $scope.timer = null;
    };

    $scope.isCurrentQuestion =
    function (question)
    {
        return "";
    };

    $scope.nextQuestion =
    function ()
    {
        $scope.currentQuestion ++;   
    };


But if the value of question is the same as the value of currentQuestion, return "current".

js\main.js
    $scope.isCurrentQuestion =
    function (question)
    {
        if ($scope.currentQuestion == question) return "current";

        return "";
    };


Refresh. Click the "Begin" button. You'll see that only the first question in the questions array is displayed! That's because the initial value of currentQuestion was -1, and clicking on the "Begin" button incremented currentQuestion by 1, making it 0... which points to the first element of the questions array!


Now let's populate the functionality of the buttons. For the "Quit" button, set it to run the initQuiz() function.

index.html
                <div class="buttons">
                    <input type="button" id="btnNext" class="btn" value="">
                    <input type="button" id="btnQuit" class="btn" value="Quit" ng-click="initQuiz()">
                </div>


For the blank button, set it to run the nextQuestion() function. And also put in a template string (using the variable proceed)  as its value.

index.html
                <div class="buttons">
                    <input type="button" id="btnNext" class="btn" value="{{proceed}}" ng-click="nextQuestion()">
                    <input type="button" id="btnQuit" class="btn" value="Quit" ng-click="initQuiz()">
                </div>


Refresh. Click the "Begin" button. Now you'll see that the previously blank button now says "Next". If you click the "Quit" button, you should go back to the main section, because initQuiz() sets the value of currentQuestion to -1. If you click "Next", you should see the next question in the questions array, like so.

And if you keep clicking "Next", you should end up here! And that's it for general flow - from the landing page, to questions, to results.


Time to work on the questions

Or rather, work on providing the users the means to answer those questions. Go to the div styled using the CSS class answers. In the div styled using selector, insert a div. Style it using the optionSelector class.

index.html
                        <div class="answers">
                            <div class="option" ng-repeat="option in question.options">
                                <div class="selector">
                                    <div class="optionSelector"></div>
                                </div>

                                <div class="text">
                                    <div class="optionText">{{option.text}}</div>
                                    <div class="optionExplanation">{{question.explanation}}</div>
                                </div>
                            </div>
                        </div>


Time to write that class! The objective here is visibility, so I wouldn't sweat too much over how it looks. Change whatever you need here. I'm just going to go with a solid light grey border and make it round. Since it's supposed to be clickable, I'm setting the cursor property to pointer.

css\styles.css
.pnlQuestions .content.current
{
    display: block;
}

.optionSelector
{
    width: 0.8em;
    height: 0.8em;
    border: 1px solid #AAAAAA;
    cursor: pointer;
    border-radius: 50%;
    float: left;
    background-color: transparent;
}


There. Not too pretty, but let's not worry about it right now.


Before we continue, you may have noticed that the explanation text is on every line, which is both irritating and redundant. Let's fix that in the CSS.

css\styles.css
.optionSelector
{
    width: 0.8em;
    height: 0.8em;
    border: 1px solid #AAAAAA;
    cursor: pointer;
    border-radius: 50%;
    float: left;
    background-color: transparent;
}

.optionExplanation
{
    visibility: hidden;
}


Now they're gone.


And in that div, add this to the style attribute. The isAnswered() function and getIndicator() function will supply the other classes. For both these functions, pass in the current index of questions as an argument. For getIndicator(), also pass in the current index of options. Note that since options is nested within questions, the current index of questions is represented using $parent.index because questions is the parent. And the current index of options is merely $index.

index.html
                        <div class="answers">
                            <div class="option" ng-repeat="option in question.options">
                                <div class="selector">
                                    <div class="optionSelector"></div>
                                </div>

                                <div class="text">
                                    <div class="optionText">{{option.text}}</div>
                                    <div class="optionExplanation {{isAnswered($parent.$index)}} {{getIndicator($parent.$index, $index)}}">{{question.explanation}}</div>
                                </div>
                            </div>
                        </div>


Create these two functions.

js\main.js
    $scope.initQuiz =
    function()
    {   
        $scope.result = 0;
        $scope.currentQuestion = -1;
        $scope.secondsRemaining = 60;
        $scope.proceed = "Next";
        $scope.timer = $interval.cancel($scope.timer);
        $scope.timer = null;
    };

    $scope.getIndicator =
    function (question, option)
    {

    };

    $scope.isAnswered =
    function (question)
    {

    };

    $scope.isCurrentQuestion =
    function (question)
    {
        if ($scope.currentQuestion == question) return "current";

        return "";
    };


isAnswered() accepts a parameter, question, and returns an empty string by default.

js\main.js
    $scope.getIndicator =
    function (question, option)
    {

    };

    $scope.isAnswered =
    function (question)
    {
        return "";
    };


This If block basically ensures that question, which is the index of the current question, is in the correct range.

js\main.js
    $scope.getIndicator =
    function (question, option)
    {

    };

    $scope.isAnswered =
    function (question)
    {
        if (question > -1 && question < $scope.questions.length)
        {
       
        }

        return "";
    };


Then it iterates through the options array of the current element of questions. If any one of the elements' selected property is "selected", return "answered".

js\main.js
    $scope.getIndicator =
    function (question, option)
    {

    };

    $scope.isAnswered =
    function (question)
    {
        if (question > -1 && question < $scope.questions.length)
        {
            for (var i = 0; i < $scope.questions[question].options.length; i++)
            {
                if ($scope.questions[question].options[i].selected == "selected") return "answered";
            }           
        }

        return "";
    };


For getIndicator(), use question to get the appropriate element of the questions array. If the answer property is the same as option, return "correct"; otherwise, return "wrong". This function will be reused later.

js\main.js
    $scope.getIndicator =
    function (question, option)
    {
        if ($scope.questions[question].answer == option)
        {
            return "correct";
        }
        else
        {
            return "wrong";
        }
    };

    $scope.isAnswered =
    function (question)
    {
        if (question > -1 && question < $scope.questions.length)
        {
            for (var i = 0; i < $scope.questions[question].options.length; i++)
            {
                if ($scope.questions[question].options[i].selected == "selected") return "answered";
            }           
        }

        return "";
    };


One more thing to do - get user input. When someone clicks on one of the light grey circles, he is essentially choosing an option. So add the ng-click attribute and make it run the selectOption() function, passing in the current index of questions and the current index of options.

index.html
                        <div class="answers">
                            <div class="option" ng-repeat="option in question.options">
                                <div class="selector">
                                    <div class="optionSelector" ng-click="selectOption($parent.$index, $index)"></div>
                                </div>

                                <div class="text">
                                    <div class="optionText">{{option.text}}</div>
                                    <div class="optionExplanation {{isAnswered($parent.$index)}} {{getIndicator($parent.$index, $index)}}">{{question.explanation}}</div>
                                </div>
                            </div>
                        </div>


Create the selectOption() function. Like getIndicator(), it will accept parameters question and option.

js\main.js
    $scope.getIndicator =
    function (question, option)
    {
        if ($scope.questions[question].answer == option)
        {
            return "correct";
        }
        else
        {
            return "wrong";
        }
    };

    $scope.selectOption =
    function (question, option)
    {
   
    };

    $scope.isAnswered =
    function (question)
    {
        if (question > -1 && question < $scope.questions.length)
        {
            for (var i = 0; i < $scope.questions[question].options.length; i++)
            {
                if ($scope.questions[question].options[i].selected == "selected") return "answered";
            }           
        }

        return "";
    };


From here, we call the isAnswered() function, passing in question as an argument. If it is not answered, then we begin by grabbing the option selected (indicated by option) and setting the selected property to "selected".

js\main.js
    $scope.getIndicator =
    function (question, option)
    {
        if ($scope.questions[question].answer == option)
        {
            return "correct";
        }
        else
        {
            return "wrong";
        }
    };

    $scope.selectOption =
    function (question, option)
    {
        if ($scope.isAnswered(question) != "answered")   
        {
            $scope.questions[question].options[option].selected = "selected";
        }   
    };
   
    $scope.isAnswered =
    function (question)
    {
        if (question > -1 && question < $scope.questions.length)
        {
            for (var i = 0; i < $scope.questions[question].options.length; i++)
            {
                if ($scope.questions[question].options[i].selected == "selected") return "answered";
            }           
        }

        return "";
    };


And if option is equal to the answer property of the element of questions pointed to by question, increment result. This essentially means that if the user answered the question correctly, he gets one more point.

js\main.js
    $scope.getIndicator =
    function (question, option)
    {
        if ($scope.questions[question].answer == option)
        {
            return "correct";
        }
        else
        {
            return "wrong";
        }
    };

    $scope.selectOption =
    function (question, option)
    {
        if ($scope.isAnswered(question) != "answered")   
        {
            $scope.questions[question].options[option].selected = "selected";

            if (option == $scope.questions[question].answer)
            {
                $scope.result++;
            }
        }   
    };
   
    $scope.isAnswered =
    function (question)
    {
        if (question > -1 && question < $scope.questions.length)
        {
            for (var i = 0; i < $scope.questions[question].options.length; i++)
            {
                if ($scope.questions[question].options[i].selected == "selected") return "answered";
            }           
        }

        return "";
    };


Try the quiz. Click on any one of the options in the first question. The explanation should appear under the correct option! Yes, Torres scored 30 to 35 goals in his maiden season at Liverpool.


Click "Next" and continue answering all the questions. When you get to the final panel, what happens? Yes, you get a message showing you how many answers you got right.


Of course, at this point, the "Restart" button is supposed to restart the quiz, so add the ng-click attribute and set it to run the initQuiz() function.

index.html
            <div class="pnlResults {{getStage()}}" style="background-image: url(img/results_{{resultsGrade}}.jpg)">
                <div class="content">
                    <h1>{{secondsRemainingMessage}}</h1>
                    <p>Your score is <b>{{result}}</b> out of <b>{{possible}}</b>.</p>
                    <p>{{resultsMessage}}</p>
                </div>

                <div class="buttons">
                    <input type="button" id="btnRestart" class="btn" value="Restart" ng-click="initQuiz()">
                </div>
            </div>


But there's a problem. When you click "Restart" and try to redo the quiz, you'll find that the explanation text is there in the questions (they should be hidden instead) and no matter what you answer, your score is always 0. That's because initQuiz() resets result to 0, but doesn't reset the questions to their unanswered state!

So, in the initQuiz() function, do this. Iterate through the questions array. Within that For loop, iterate through the options array. Set each selected property to an empty string.

js\main.js
    $scope.initQuiz =
    function()
    {   
        $scope.result = 0;
        $scope.currentQuestion = -1;
        $scope.secondsRemaining = 60;
        $scope.proceed = "Next";
        $scope.timer = $interval.cancel($scope.timer);
        $scope.timer = null;

        for (var i = 0; i < $scope.questions.length; i++)
        {
            for (var j = 0; j < $scope.questions[i].options.length; j++)
            {
                $scope.questions[i].options[j].selected = "";
            }           
        }
    };


Now where you click "Restart" at the end or "Quit" while answering the questions, the quiz behaves appropriately!

Setting up a timer

This part is optional, but loads of fun, so let's do it. In the questions panel, before the part where we iterate through the questions array, add a div and style it using timer. Within this, add a paragraph tag and a template string for secondsRemaining.

index.html
            <div class="pnlQuestions {{getStage()}}">
                <div class="timer">
                    <p>{{secondsRemaining}}</p>
                </div>
               
                <div ng-repeat="question in questions">
                    <div class="content {{isCurrentQuestion($index)}}">
                        <div class="question">
                            <div class="questionText">
                                {{question.text}}
                            </div>

                            <div class="questionProgress">
                               
                            </div>   
                        </div>

                        <br style="clear:both">

                        <div class="answers">
                            <div class="option" ng-repeat="option in question.options">
                                <div class="selector">
                                    <div class="optionSelector" ng-click="selectOption($parent.$index, $index)"></div>
                                </div>

                                <div class="text">
                                    <div class="optionText">{{option.text}}</div>
                                    <div class="optionExplanation {{isAnswered($parent.$index)}} {{getIndicator($parent.$index, $index)}}">{{question.explanation}}</div>
                                </div>
                            </div>
                        </div>

                        <br style="clear:both">
                    </div>
                </div>


Do you see "60" at the top? That's the number of seconds remaining, as defined by initQuiz(). Problem is, it doesn't change.


In the nextQuestion() function, add a conditional, checking if timer is null.

js\main.js
    $scope.nextQuestion =
    function ()
    {
        if ($scope.timer == null)
        {

        }

        $scope.currentQuestion ++;   
    };


If it is, then we set timer using the AngularJS object $interval. $interval basically acts like the setInterval() function here, specifying a callback and an interval in milliseconds.

js\main.js
    $scope.nextQuestion =
    function ()
    {
        if ($scope.timer == null)
        {
            $scope.timer = $interval
            (
                function()
                {

                },
                1000
            );
        }

        $scope.currentQuestion ++;   
    };


In the callback, decrement secondsRemaining.

js\main.js
    $scope.nextQuestion =
    function ()
    {
        if ($scope.timer == null)
        {
            $scope.timer = $interval
            (
                function()
                {
                    $scope.secondsRemaining--;
                },
                1000
            );
        }

        $scope.currentQuestion ++;   
    };


If secondsRemaining has fallen below 0, immediately end the quiz by setting currentQuestion to the length of the questions array, and cancel timer by running the cancel() method of the $interval object.

js\main.js
    $scope.nextQuestion =
    function ()
    {
        if ($scope.timer == null)
        {
            $scope.timer = $interval
            (
                function()
                {
                    $scope.secondsRemaining--;

                    if ($scope.secondsRemaining < 0)
                    {
                        $scope.currentQuestion = $scope.questions.length;
                        $scope.timer = $interval.cancel($scope.timer);
                    }
                },
                1000
            );
        }

        $scope.currentQuestion ++;   
    };


Now, modify the functon slightly. Make currentQuestion increment only if the current question has been answered (use the isAnswered() function here) or if currentQuestion is at -1 (meaning, the user is at the main section.). This effectively means that the user cannot proceed if the current question is not answered, but the "Next" button will still function normally at the main section.

js\main.js
    $scope.nextQuestion =
    function ()
    {
        if ($scope.timer == null)
        {
            $scope.timer = $interval
            (
                function()
                {
                    $scope.secondsRemaining--;

                    if ($scope.secondsRemaining < 0)
                    {
                        $scope.currentQuestion = $scope.questions.length;
                        $scope.timer = $interval.cancel($scope.timer);
                    }
                },
                1000
            );
        }

        if ($scope.isAnswered($scope.currentQuestion) == "answered" || $scope.currentQuestion == -1)
        {
            $scope.currentQuestion ++;   
        }
    };


Add one more conditional block. After incrementing, check if we're at the end of the questions array. If we are, cancel the timer.

js\main.js
    $scope.nextQuestion =
    function ()
    {
        if ($scope.timer == null)
        {
            $scope.timer = $interval
            (
                function()
                {
                    $scope.secondsRemaining--;

                    if ($scope.secondsRemaining < 0)
                    {
                        $scope.currentQuestion = $scope.questions.length;
                        $scope.timer = $interval.cancel($scope.timer);
                    }
                },
                1000
            );
        }

        if ($scope.isAnswered($scope.currentQuestion) == "answered" || $scope.currentQuestion == -1)
        {
            $scope.currentQuestion ++;   

            if ($scope.currentQuestion == $scope.questions.length)
            {
                $scope.timer = $interval.cancel($scope.timer);
            }
        }
    };


Time to test! The figure at the top should be decrementing every second, and when it reaches 0 before you have completed the quiz, it should go straight to results. If you complete the quiz, it should stop decrementing automatically.


Next

Layout, presentation, and all that jazz! I will show you how to make this quiz more user-friendly.

No comments:

Post a Comment