Building a Dynamic Quiz Using Craft CMS and React.js
Websites use dynamic quizzes or wizards to retrieve information from users in a prescribed order, allowing for subsequent answers to manipulate future shown steps.
These quizzes usually give a customized result based on the responses given.
This quiz can be hardcoded in whatever system you are using. However, we are in an age where content is constantly changing, so having your website content be easily maintained by a CMS makes things a lot easier.
In this tutorial, we will build a dynamic quiz/wizard managed completely in Craft CMS. The application displays questions based on the responses given by the user and, at the end of the quiz, displays the answers.
This tutorial assumes that you have a basic knowledge of Craft CMS and React.js.
Setup
To make this tutorial simpler, I created a starter project by building out the CMS and adding the base files for styling the components. If you would like to code alongside the article, follow the steps below:
- Clone the project:
git clone –branch initial-setup https://github.com/zaengle/dynamic-wizard.git
- Follow the steps in the readme
Now that you have completed the setup, we can get started.
Passing data from Craft CMS to our React component
Retrieve data from Craft CMS
To get things started, we will pull the quiz data from Craft CMS by adding the below code to the index.twig
file. This code block pulls questions from Craft CMS and formats them so the data can be more easily consumed by the React application.
{% set questions = craft.entries().section('quizSteps').with([
'skipToQuestion',
'answers.answer',
'answers.answer:answerId',
'answers.answer:answerProceedsTo',
'backTo',
'nextTo'
]).collect()
%}
{% set quizSteps = [] %}
{% for question in questions %}
{% if question.type.handle == 'question' %}
{% set quizSteps = quizSteps|merge([{
id: question.uid,
type: question.questionType.value,
title: question.title,
description: question.questionDescription,
allowSkip: question.allowSkip,
skipToQuestion: question.skipToQuestion.first().uid ?? null,
answers: question.answers|map((answer) => {
title: answer.answerTitle,
proceedsTo: answer.answerProceedsTo.first().uid ?? null,
}),
}]) %}
{% elseif question.type.handle == 'interstitial' %}
{% set quizSteps = quizSteps|merge([{
id: question.uid,
type: question.type.handle,
title: question.title,
description: question.questionDescription,
backTo: question.backTo.first().uid ?? null,
nextTo: question.nextTo.first().uid ?? null,
}]) %}
{% endif %}
{% endfor %}
{% dd(quizSteps) %}
Passing the data to the React component
To pass the data to the React component, we use data
attributes. We also use the json_encode
Twig filter to assist in passing the data; otherwise, the app will throw an array to string conversion error
. The id
attribute on the div
element is used to identify which React component is rendered.
<div id="quiz" data-questions="{{ quizSteps | json_encode }}"></div>
Add the above to the index.twig
file. Our Craft data has now been passed to our React component.
Retrieving data in our React component
We use the dataset
property on the element to pull out all the data attributes we added to our component. We then parse the data from a string using JSON.parse()
.
import {createRoot} from 'react-dom/client'
import PropTypes from 'prop-types'
Quiz.propTypes = {
questions: PropTypes.arrayOf(questionType).isRequired,
}
if (document.getElementById('quiz')) {
const container = document.getElementById('quiz')
const props = Object.assign({}, container.dataset)
const root = createRoot(container)
props.questions = JSON.parse(props.questions)
root.render(<Quiz {...props} />)
}
After this, the React component is able to use the data.
Building the wizard
The steps
To show that the application is indeed receiving the questions, we will display all of the questions. In the component, we accept the questions as a prop and then, in a loop, display the question title. We also validate that the questions
prop is an array and is not empty.
const Quiz = ({questions}) => {
const isQuestionsValid = Array.isArray(questions) || questions.length === 0
if (!isQuestionsValid) {
return null
}
return (
questions.map((question) => <p>{question.title}</p>)
)
}
Displaying one question at a time
Now that we know we are getting all of the questions, we will start building out the quiz.
First, we set up variables to manage the current step and question.
const [currentStep, setCurrentStep] = useState(0)
const [currentQuestion, setCurrentQuestion] = useState(
isQuestionsValid ? questions[currentStep] : null,
)
Using the current question, we switch from rendering a loop to rendering only the current question. This approach provides a couple of advantages:
- We can switch the current question based on a user’s response.
- Different responses can correspond to different paths through the wizard.
Next, we update the render to display the correct question type view. We currently have 3 types of questions and an interstitial state in this application. An interstitial state can be used for providing information to the user (e.g. if a user selects under 18 you can say sorry you do not meet the qualifications for this quiz). The question types are:
- Single select questions - radio
- Multiselect questions - checkboxes
- Stepped range
To each of these components, we are going to pass the current question as a prop. We loop through the answers in the component and render the options based on the question type.
...
const renderQuestion = (question) => {
switch (question?.type) {
case 'singleSelect':
return (
<SingleSelectField
question={question}
/>
)
case 'multiSelect':
return (
<MultiSelectField
question={question}
/>
)
case 'steppedRange':
return (
<SteppedRangeField
question={question}
/>
)
default:
return (
<p>Question type not supported</p>
)
}
}
return (
<section className="max-w-2xl p-5 md:p-7 lg:p-10 mx-auto border-2 border-black mt-10">
{currentQuestion.type === 'interstitial' ? (
<Interstitial currentQuestion={currentQuestion} />
) : (
<>
<fieldset>
<legend className="mb-5 flex flex-col gap-4">
<h1 className="text-3xl font-bold">{currentQuestion?.title}</h1>
{currentQuestion?.description &&
<div className="prose" dangerouslySetInnerHTML={{__html: currentQuestion?.description}}/>
}
</legend>
{renderQuestion(currentQuestion)}
</fieldset>
<div className="mt-10 flex flex-col gap-4 md:flex-row md:justify-between">
<div className="flex items-center gap-3 w-full">
<p className="text-blue-700 text-lg font-bold">10%</p>
<div className="w-full h-2 bg-gray-300 rounded-full max-w-[250px]">
<div className="h-2 bg-blue-700 rounded-full" style={{ width: '10%' }}/>
</div>
</div>
<div className="flex justify-between gap-4 md:justify-start">
<button
className="px-3 py-2 border-2 border-blue-700 text-sm uppercase font-medium text-blue-700 bg-transparent hover:bg-blue-900 hover:border-blue-900 hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<button
className="px-3 py-2 border-2 border-blue-700 text-sm uppercase font-medium text-white bg-blue-700 hover:bg-blue-900 hover:border-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
</>
)}
</section>
)
...
Storing the current answer
In addition to displaying the current question, we need to allow for storing the currently selected answer. This allows us to determine which question will be selected next based on the user's answer.
const [currentAnswer, setCurrentAnswer] = useState([])
We need to update the renderQuestion
function to pass these props.
case 'singleSelect':
return (
<SingleSelectField
question={question}
currentAnswer={currentAnswer}
setCurrentAnswer={setCurrentAnswer}
/>
)
case 'multiSelect':
return (
<MultiSelectField
question={question}
currentAnswer={currentAnswer}
setCurrentAnswer={setCurrentAnswer}
/>
)
case 'steppedRange':
return (
<SteppedRangeField
question={question}
currentAnswer={currentAnswer}
setCurrentAnswer={setCurrentAnswer}
/>
)
Connecting the next and previous buttons
Now that we have the logic for displaying the current question, we can wire up the Next
and Previous
buttons.
For this wizard, we are not always going in a linear direction based on our questions
array, so we need to add a new function, goToStep
.
const goToStep = (stepIndex) => {
if (stepIndex >= 0 && stepIndex < questions.length) {
setCurrentStep(stepIndex)
setCurrentQuestion(questions[stepIndex])
} else {
console.warn(
[
`Invalid step index [${stepIndex}] passed to 'goToStep'. `,
`Ensure the given stepIndex is not out of boundaries.`,
].join(''),
)
}
}
Step history
We need to track the route the user took through the wizard because it’s not a linear wizard. This wizard is only storing the question as we select them.
const [stepHistory] = useState([])
Next button
The user should be able to click the Next
button if the user chooses an answer and there is a next question available. We need to add the nextQuestion
function to handle this logic.
...
// This function is used to find the index of a question in the questions array by the question id
const getIndex = id => questions.findIndex(question => question.id === id)
const nextQuestion = () => {
stepHistory.push(currentStep)
// We need to sort the answers array with the first available question
setCurrentAnswer(
currentAnswer.sort(
(a, b) => getIndex(a.proceedsTo) - getIndex(b.proceedsTo),
),
)
goToStep(getIndex(currentAnswer[0]?.proceedsTo))
setCurrentAnswer([])
}
...
<button
className="px-3 py-2 border-2 border-blue-700 text-sm uppercase font-medium text-white bg-blue-700 hover:bg-blue-900 hover:border-blue-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => nextQuestion()}
disabled={currentAnswer.length === 0}
>
Next
</button>
...
Previous button
The user should be able to click the Previous
button if it’s not the first question and there is a next question available. The previousQuestion
function handles this logic.
...
const isFirstStep = currentQuestion && questions[0].id === currentQuestion.id
...
const previousQuestion = () => {
let id = stepHistory.pop()
goToStep(id)
}
...
{!isFirstStep && (
<button
className="px-3 py-2 border-2 border-blue-700 text-sm uppercase font-medium text-blue-700 bg-transparent hover:bg-blue-900 hover:border-blue-900 hover:text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={() => previousQuestion()}
disabled={isFirstStep}
>
Previous
</button>
)}
...
Interstitial
Finally, we need to update the Next
and Previous
buttons found in the interstitial step.
<Interstitial currentQuestion={currentQuestion}
goToStep={goToStep} getIndex={getIndex} />
Allowing questions to be skipped
Not all questions need to be answered, so we should allow users to skip a question. We need to add a function to provide this functionality.
const skipQuestion = (id) => {
goToStep(getIndex(id))
}
Next, we update the UI to show the Skip Question
button.
{renderQuestion(currentQuestion)}
{ currentQuestion?.allowSkip && currentQuestion?.skipToQuestion && (
<button className="capitalize text-sm font-bold text-blue-700 mt-8 hover:underline" onClick={() => skipQuestion(currentQuestion?.skipToQuestion)}>
skip this question
</button>
)}
Displaying percentage completed
Currently, we have the progress bar showing but it is not updating as we go through the wizard. To calculate the percentage completed we first check that the array of questions is an array and not empty. If it is empty or not an array we set the percentage to 0. If the array of questions is valid, we divide the currentStep
count (adding one because we have a zero-based index) by the number of questions in the array then multiply that number by 100 to get the percentage. We then use the toFixed
function to round off that percentage to the nearest whole number.
const percentage = isQuestionsValid
? Number((((currentStep + 1) / questions.length) * 100).toFixed(0))
: 0
<div className="flex items-center gap-3 w-full">
<p className="text-blue-700 text-lg font-bold">{percentage + '%'}</p>
<div className="w-full h-2 bg-gray-300 rounded-full max-w-[250px]">
<div className="h-2 bg-blue-700 rounded-full" style={{ width: percentage + '%' }}/>
</div>
</div>
The Answers
Now that we have the wizard working properly, we will update the quiz to store each answer the user selected.
1. Add some local state to store the answers.
const [answers, setAnswers] = useState(
isQuestionsValid &&
questions.map((question) => {
return {id: question.id, answer: []}
}),
)
2. Add a function to store answers after a user selects one and clicks Next
.
const addAnswer = () => {
setAnswers((prevState) => {
const newState = prevState.map((answer) => {
if (answer.id === currentQuestion.id) {
return {...answer, answer: currentAnswer}
}
return answer
})
return newState
})
}
3. Update the nextQuestion
function. We will add the addAnswer
function to store the user's answer. We will then go through our answers array to see if there is an answer stored for the next question and set it as the currentAnswer
.
const nextQuestion = () => {
stepHistory.push(currentStep)
// We need to sort the answers array with the first available question
setCurrentAnswer(
currentAnswer.sort(
(a, b) => getIndex(a.proceedsTo) - getIndex(b.proceedsTo),
),
)
addAnswer()
goToStep(getIndex(currentAnswer[0]?.proceedsTo))
// We need to filter the answers array by the next question id and set it as the current answer for the next question
let answer = answers.filter(
answer => answer.id === currentAnswer[0]?.proceedsTo,
)
setCurrentAnswer(answer[0].answer)
}
4. Update the previousQuestion
function so we show the previous answer the user selected.
const previousQuestion = () => {
let id = stepHistory.pop()
goToStep(id)
// We need to filter the answers array by the previous question id and set it as the current answer
let answer = answers.filter(answer => answer.id === questions[id].id)
setCurrentAnswer(answer[0].answer)
}
5. Update the skipQuestion
function to go through our answers array to see if there is an answer stored for the next question and set it as the currentAnswer
.
const skipQuestion = (id) => {
let answer = answers.filter(answer => answer.id === id)
setCurrentAnswer(answer[0].answer)
goToStep(getIndex(id))
}
6. Update the renderQuestion
function. To ensure the stepped range has a good value on the initial render we will set the currentAnswer
to the first value in the possible answers. If we do not make this update the rendering will be a bit wonky.
const renderQuestion = (question) => {
if (question?.type === 'steppedRange' && currentAnswer.length === 0) {
setCurrentAnswer([question?.answers[0]])
}
The result
To show that we collected answers from the user, we will display all answered questions and the user’s answers on the last step of the wizard.
const isLastStep = currentQuestion && questions[questions.length - 1].id === currentQuestion.id
…
<Interstitial currentQuestion={currentQuestion} goToStep={goToStep} getIndex={getIndex}>
{isLastStep ? (
<Answers answers={answers} questions={questions} />
) : null}
</Interstitial>
Conclusion
You should now have a fully working dynamic wizard. To extend this application, you can:
- Send the results back to the Craft backend
- Add additional question types
Want to read more tips and insights on working with a Craft CMS development team that wants to help your organization grow for good? Sign up for our bimonthly newsletter.
Engineer
Jasmine has a background in software testing and she loves going on adventures, reading books, watching murder mysteries and doing jigsaw puzzles.