In this tutorial, we will make a page where users will finally be able to take quizzes.
Let's start by adding a show
method in the HomeController
and adding a route with the view.
app/Http/Controllers/HomeController.php:
class HomeController extends Controller{ // ... public function show(Quiz $quiz, $slug = null) { return view('front.quizzes.show', compact( 'quiz')); }}
routes/web.php:
Route::get('/', [HomeController::class, 'index'])->name('home');Route::get('quiz/{quiz}/{slug?}', [HomeController::class, 'show'])->name('quiz.show'); // ...
Next, we need to add this route for quizzes on the homepage.
resources/views/home.blade.php:
// ...<a href="{{ route('quiz.show', [$quiz, $quiz->slug]) }}">{{ $quiz->title }}</a>// ...
Now for showing the quiz first we need to make a check if the quiz isn't public and the user isn't a guest, because we don't want to guests to take quizzes that are only for registered users. First, create a new view file resources/views/front/quizzes/show.blade.php
.
resources/views/front/quizzes/show.blade.php:
<x-app-layout> <x-slot name="header"> <h2 class="font-semibold text-xl text-gray-800 leading-tight"> {{ $quiz->title }} </h2> </x-slot> <div class="py-12"> <div class="max-w-7xl mx-auto sm:px-6 lg:px-8"> <div class="bg-white overflow-hidden shadow-sm sm:rounded-lg"> <div class="p-6 text-gray-900"> @if (! $quiz->public && ! auth()->check()) <div class="text-white px-6 py-4 border-0 rounded relative mb-4 bg-indigo-500"> <span class="inline-block align-middle mr-8"> This test is available only for registered users. Please <a href="{{ route('login') }}" class="hover:underline">Log in</a> or <a href="{{ route('register') }}" class="hover:underline">Register</a> </span> </div> @else The livewire component will go here @endif </div> </div> </div> </div></x-app-layout>
If the guest will visit such a quiz page they will see a message like below:
Next, we need to create a Livewire component where all the logic for showing questions will be.
php artisan make:livewire Front/Quizzes/Show
And add a component to the quizzes show view.
resources/views/front/quizzes/show.blade.php:
// ...@if (! $quiz->public && ! auth()->check()) <div class="text-white px-6 py-4 border-0 rounded relative mb-4 bg-indigo-500"> <span class="inline-block align-middle mr-8"> This test is available only for registered users. Please <a href="{{ route('login') }}" class="hover:underline">Log in</a> or <a href="{{ route('register') }}" class="hover:underline">Register</a> </span> </div>@else <livewire:front.quizzes.show :quiz="$quiz" /> @endif// ...
In the Livewire component let's start by adding public properties.
app/Livewire/Front/Quizzes/Show.php:
use App\Models\Quiz;use Livewire\Component;use App\Models\Question;use Illuminate\Contracts\View\View;use Illuminate\Database\Eloquent\Collection; class Show extends Component{ public Quiz $quiz; public Collection $questions; public Question $currentQuestion; public int $currentQuestionIndex = 0; public array $questionsAnswers = []; public int $startTimeSeconds = 0; public function render(): View { return view('livewire.front.quizzes.show'); }}
So what every property will have here?
$quiz
for current taken Quiz.$questions
a Collection of questions for the Quiz.$currentQuestion
will have a current question that needs to be answered on the screen.$currentQuestionIndex
is the key number of which Question to show.$questionsAnswers
is where users selected answers will be set.$startTimeSeconds
will have the time in seconds when the quiz was started.Next, we will add the mount
method.
app/Livewire/Front/Quizzes/Show.php:
class Show extends Component{ // ... public function mount(): void { $this->startTimeSeconds = now()->timestamp; $this->questions = Question::query() ->inRandomOrder() ->whereRelation('quizzes','id', $this->quiz->id) ->with('questionOptions') ->get(); $this->currentQuestion = $this->questions[$this->currentQuestionIndex]; for($i = 0; $i < $this->questionsCount; $i++) { $this->questionsAnswers[$i] = []; } } // ...}
So, what do we set when the Livewire component gets mounted?
As you can see for the questions count instead of $this->questions->count()
we use $this->questionsCount
. Here we use a computed property which gets cached and we can reuse its value.
app/Livewire/Front/Quizzes/Show.php:
use Livewire\Attributes\Computed; class Show extends Component{ // ... public function mount(): void { $this->startTimeSeconds = now()->timestamp; $this->questions = Question::query() ->inRandomOrder() ->whereRelation('quizzes', 'id', $this->quiz->id) ->with('questionOptions') ->get(); $this->currentQuestion = $this->questions[$this->currentQuestionIndex]; for($i = 0; $i < $this->questionsCount; $i++) { $this->questionsAnswers[$i] = []; } } #[Computed] public function questionsCount(): int { return $this->questions->count(); } // ...}
Now we can start showing questions for the user in the quiz.
resources/views/livewire/front/quizzes/show.blade.php:
<div> <span class="text-bold">Question {{ $currentQuestionIndex + 1 }} of {{ $this->questionsCount }}:</span> <h2 class="mb-4 text-2xl">{{ $currentQuestion->question_text }}</h2> @if ($currentQuestion->code_snippet) <pre class="mb-4 border-2 border-solid bg-gray-50 p-2">{{ $currentQuestion->code_snippet }}</pre> @endif @foreach($currentQuestion->questionOptions as $option) <div> <label for="option.{{ $option->id }}"> <input type="radio" id="option.{{ $option->id }}" wire:model="questionsAnswers.{{ $currentQuestionIndex }}" name="questionsAnswers.{{ $currentQuestionIndex }}" value="{{ $option->id }}"> {{ $option->option }} </label> </div> @endforeach @if ($currentQuestionIndex < $this->questionsCount - 1) <div class="mt-4"> <x-secondary-button> Next question </x-secondary-button> </div> @else <div class="mt-4"> <x-primary-button wire:click="submit">Submit</x-primary-button> </div> @endif</div>
Nothing special we do in this view file. Just showing some information like the number of the question of total questions. If there is a code snippet then show it. Going through current question options and showing them, binding to the $questionsAswers
property. And the last thing, if there are more questions, we show the Next question
button otherwise we show the Submit
button.
After visiting the quiz you should see a similar result below:
Now that users can see questions, we need to make buttons work. First, for the Next question
button, let's add the changeQuestion
action.
resources/views/livewire/front/quizzes/show.blade.php:
// ...<div class="mt-4"> <x-secondary-button wire:click="changeQuestion"> Next question </x-secondary-button></div>// ...
In the changeQuestion
method, we need to add +1 for the $currentQuestionIndex
and set the $currentQuestion
to the question from the $questions
where the key would be $currentQuestionIndex
.
app/Livewire/Front/Quizzes/Show.php:
class Show extends Component{ // ... public function changeQuestion(): void { $this->currentQuestionIndex++; $this->currentQuestion = $this->questions[$this->currentQuestionIndex]; } // ...}
Now we can go to the next question.
For the Submit
button we will add a submit
method. But for submitting for now let's just add a dd('submit')
and we will implement it later.
resources/views/livewire/front/quizzes/show.blade.php:
// ... @else <div class="mt-4"> <x-primary-button wire:click.prevent="submit">Submit</x-primary-button> </div> @endif</div>
app/Livewire/Front/Quizzes/Show.php:
class Show extends Component{ // ... public function submit() { dd('submit'); } // ...}
The last thing for this lesson, using Alpine.js let's add a timer, for how much time a user has to answer a question. The value for how long the user has to answer, we will save in config/quiz.php
in seconds.
config/quiz.php:
return [ 'secondsPerQuestion' => 60,];
And now we can add a timer to the frontend. In the Livewire component in the root div element first, we need to add the x-data
element and provide secondsLeft
as reactive data with the value from the config. Next, we need to use x-init
to initialize the timer. In it, we use the setInterval()
function to run commands every second. And what do we need to do every second? We check if secondsLeft
is more than 1, if so then decrease the timer by 1 second. Else, we reset secondsLeft
and using $wire
we call the changeQuestion
method in the Livewire component. And of course, we show the timer to the user using x-text
.
resources/views/livewire/front/quizzes/show.blade.php:
<div x-data="{ secondsLeft: {{ config('quiz.secondsPerQuestion') }} }" x-init="setInterval(() => { if (secondsLeft > 1) { secondsLeft--; } else { secondsLeft = {{ config('quiz.secondsPerQuestion') }}; $wire.changeQuestion(); } }, 1000);"> <div class="mb-2"> Time left for this question: <span x-text="secondsLeft" class="font-bold"></span> sec. </div>// ...
But now we need to reset the timer when the user presses the Next question
button. For this we need to change wire:click
for the Alpine.js x-on:click
and after resetting the timer call changeQuestion
using $wire
.
resources/views/livewire/front/quizzes/show.blade.php:
<x-secondary-button wire:click="changeQuestion"> <x-secondary-button x-on:click="secondsLeft = {{ config('quiz.secondsPerQuestion') }}; $wire.changeQuestion();"> Next question</x-secondary-button>
But what if there are no questions and changeQuestion
gets called? You will receive an error like below:
To fix it, we just need to add a simple if. If $currentQuestionIndex
is greater or equal we just need to call the submit()
method.
class Show extends Component{ // ... public function changeQuestion(): void { $this->currentQuestionIndex++; if ($this->currentQuestionIndex >= $this->questionsCount) { return $this->submit(); } $this->currentQuestion = $this->questions[$this->currentQuestionIndex]; } // ...}