Vuejs is my favorite javascript framework because of its flexibility. In this post, i will share with you my implementation for building a quiz app that features a collection of multiple-choice questions (about animals and birds). Users will have a limited amount of time to answer each question, and failing to do so will result in a deduction of points.
Here is a quick overview of the essential parts of the app:
If you want to follow along with me, it is recommended that you have a good understanding of the basics of Vuejs (how to create a new Vue app).
Installation
- Pinia, is a store library for Vuejs, it allows you to share a state across components/pages.
- Prime Vue, is a rich set of open source native components for Vue.
Within your Vue app:
npm install pinia primevue@^3 primeicons --save
After installing the necessary packages, we are ready to start creating the app components and store.
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import PrimeVue from 'primevue/config';
import ProgressBar from 'primevue/progressbar';
import App from '../../components/vue/quiz-app/index.vue';
const quizeApp = createApp(App);
const piniaStore = createPinia();
quizeApp.component('ProgressBar', ProgressBar);
quizeApp.use(piniaStore).use(PrimeVue).mount('#app');
Components
-
Timer Component is the heart of the app, this is because it will track the time and update the clock UI when each question becomes available for answering. In case the user runs out of time while answering a question, the component will emit two events:
-
timeout-notifying
to update the ui with a timeout message. -
count-finished
to tell Question Component (its parent component) that time's up.
-
<script setup>
import { onMounted, onUnmounted, ref } from 'vue';
import { useQuizStore } from '../../../js/vue/stores/quiz.js';
const {duration} = defineProps(['duration']);
const emit = defineEmits(['count-finished', 'timeout-notifying']);
const quizStore = useQuizStore();
const timerInterval = ref(null);
const questionDurationProgress = ref(0);
const currentSecound = ref(0);
const timeOut = ref(false);
const startTimer = () => {
timerInterval.value = setInterval((x) => {
currentSecound.value += 1;
questionDurationProgress.value = (currentSecound.value / (duration-1)) * 100;
if(currentSecound.value === duration) {
clearInterval(timerInterval.value);
timeOut.value = true;
emit('timeout-notifying');
finishing();
}
}, 1000);
};
const finishing = () => {
setTimeout(() => {
emit('count-finished');
}, 2000);
};
onMounted(() => {
startTimer();
});
</script>
<style lang="scss"></style>
<template>
<div class="px-8">
<h1 class="font-mont font-light text-8xl text-white flex justify-center">
<div class="inline-block p-20 leading-none rounded-full bg-black relative">
<span class="absolute top-1/2 left-1/2 translate-y-[-50%] translate-x-[-50%]">{{ currentSecound }}</span>
</div>
</h1>
<div class="mt-8">
<h1 class="font-mont font-bold text-4xl text-center capitalize animate__animated animate__tada" v-if="timeOut">time out !!!</h1>
<ProgressBar v-else :value="questionDurationProgress" :showValue="false" />
</div>
</div>
</template>
-
Question Component will render the question content including the answers and the timer. Each answer is clickable, picking up an answer will emit
answer-picked
event. The component also is listening to the timer'scount-finished
emitted event, and depending on it to emit its ownno-answer-picked
event. Emitting eitheranswer-picked
orno-answer-picked
events will call:-
updateProgress
store action that will record the user progress while answering the questions. -
paginate
store action that will load the next question.
-
<script setup>
import { ref } from 'vue';
import Timer from './timer.vue';
const { content } = defineProps(['content']);
const emit = defineEmits(['answer-picked', 'no-answer-picked']);
const timeout = ref(false);
const noAnswerPicked = () => {
emit('no-answer-picked', content, null);
}
const answerPicked = (answerId) => {
emit('answer-picked', content, answerId);
}
const notifyAboutTimeout = () => {
timeout.value = true
}
</script>
<template>
<div class="animation-box">
<Timer :duration="10" @timeoutNotifying="notifyAboutTimeout" @countFinished="noAnswerPicked" />
<div v-if="!timeout" class="question-content capitalize p-4 mt-8 overflow-hidden">
<div class="content-box w-full max-w-sm mx-auto">
<h1 class="text-4xl font-mont font-bold">{{ content.body }}</h1>
<ul class="mt-8">
<li class="text-3xl font-ssp mt-4" v-for="answer of content.answers" :key="answer.id">
<span class="p-2 cursor-pointer hover:border-2 hover:border-black" @click="answerPicked(answer.id)">{{ answer.body }}</span>
</li>
</ul>
</div>
</div>
</div>
</template>
<style lang="scss"></style>
-
App Component will wrap all other UI components and connect them to the store through methods (for example)
loadNextQuestion
method will call two store actionsupdateProgress
andpaginate
, then passing the payload to them.
<script setup>
import { onMounted } from 'vue';
import Question from './question.vue';
import { useQuizStore } from '../../../js/vue/stores/quiz';
const quizStore = useQuizStore();
onMounted(() => {
quizStore.paginate();
});
const loadNextQuestion = (question, answerId) => {
quizStore.updateProgress(question, answerId);
quizStore.paginate();
};
</script>
<style lang="scss"></style>
<template>
<transition name="fade" mode="out-in">
<div v-if="quizStore.quizFinished" class="p-2 flex flex-col items-center">
<h1 class="text-6xl font-mont font-bold capitalize">
<span>finished</span>
</h1>
<h2 class="text-3xl font-ssp font-bold capitalize mt-2">
<span class="px-2">score: {{ quizStore.score }} / 5</span>
</h2>
<div class="mt-4 px-2">
<ul>
<li class="mt-8" v-for="row in quizStore.result">
<h1 class="text-4xl font-mont font-bold">{{ row.body }}</h1>
<div class="text-2xl font-ssp capitalize mt-4">
<div class="feedback flex items-center">
<span>{{ row.userAnswer.body }}</span>
<span class="px-3.5 py-3 m-2 bg-black text-white rounded-full flex justify-center items-center max-w-[3rem]"><i :class="['pi', row.answerisRight?'pi-check':'pi-times']"></i></span>
</div>
<div v-if="!row.answerisRight">
<span class="font-bold">right answer is </span>
<span class="font-regular">{{ row.rightAnswer.body }}</span>
</div>
</div>
</li>
</ul>
</div>
<div class="mt-4">
<button class="text-4xl font-ssp font-bold bg-black text-white capitalize px-4 py-2" @click="quizStore.restartQuiz">restart quiz</button>
</div>
</div>
<div v-else id="question-box" class="relative overflow-hidden">
<template v-for="question of quizStore.quiz.items" :key="question.id">
<transition name="fade" mode="out-in">
<Question
:content="question"
v-if="question.id === quizStore.currentQuestionId"
@answerPicked="loadNextQuestion"
@noAnswerPicked="loadNextQuestion"
/>
</transition>
</template>
</div>
</transition>
</template>
Define a Quiz Store
Consider it a small app that will hold all business logic. In addition to the actions we already discussed, you also can declare getters within the store as a computed properties. Within this app (for example) the quiz
computed property was used to generate a unique random collection of questions.
When the quiz round has finished the store needs to be reset this can be done through restartQuiz
action. You can also change the value of version
prop and this will tell quiz
getter (computed) prop to regenerate a new collection of questions, because version
prop is one of the quiz
computed prop dependencies.
import { ref, computed } from 'vue';
import { defineStore } from 'pinia';
export const useQuizStore = defineStore('quiz', () => {
const questions = [
...
{
id: 1,
body: 'which is the tallest animal in the world?',
answers: [
{id:1, body: 'giraffe'},
{id:2, body: 'lion'},
{id:3, body: 'cheetah'},
{id:4, body: 'bat'}
],
rightAnswerId: 1
},
...
];
const currentQuestionIndex = ref(0);
const currentQuestionId = ref(null);
const quizFinished = ref(false);
const version = ref(1);
const result = ref([]);
const score = ref(0);
const quiz = computed(() => {
const max = questions.length - 1;
const min = 0;
const uniqueNumbers = new Set();
while (uniqueNumbers.size < 5) {
const randomNumber = Math.floor(Math.random() * (max - min + 1)) + min;
uniqueNumbers.add(randomNumber);
}
const questionsCollection = [...uniqueNumbers].map(index => questions[index]);
return {
version: version.value,
items: questionsCollection
};
});
const updateProgress = (question, answerId) => {
const answerisRight = question.rightAnswerId === answerId;
if(answerisRight) score.value += 1;
result.value = [...result.value, {
...question,
userAnswer: question.answers.filter((choice) => answerId === choice.id)[0] ?? {body:'no answer'},
answerisRight,
rightAnswer: question.answers.filter((choice) => question.rightAnswerId === choice.id)[0]
}];
};
const paginate = () => {
if(currentQuestionIndex.value === quiz.value.items.length) return quizFinished.value = true;
currentQuestionId.value = quiz.value.items[currentQuestionIndex.value].id;
currentQuestionIndex.value += 1;
};
const restartQuiz = () => {
currentQuestionIndex.value = 0;
currentQuestionId.value = null;
quizFinished.value = false;
result.value = [];
score.value = 0;
version.value += 1;
paginate();
};
return {
questions,
quizFinished,
quiz,
currentQuestionId,
paginate,
restartQuiz,
updateProgress,
result,
score
};
});
Conclusion
Vuejs features rich ecosystem, Pinia is a very powerfull state management library that helps you manage and store reactive data and state across your components in your Vuejs applications. The components necessary for creating the App are wrapping the UI, and pinia quiz store is responsible for isolating the App's working logic.
Your feedback is much appreciated, thank you for reading.
Top comments (2)
Nice! I was thinking of a similar app targeted to developers!
You know just helping devs keep contact with a technology.
Nice article!
It's great you like it nickap