Skip to main content

By Jasmine Tracey

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:

  1. Clone the project: git clone –branch initial-setup https://github.com/zaengle/dynamic-wizard.git
  2. 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:

  1. We can switch the current question based on a user’s response.
  2. 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.

By Jasmine Tracey

Engineer

Jasmine has a background in software testing and she loves going on adventures, reading books, watching murder mysteries and doing jigsaw puzzles.