Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds timer feature with pause/resume and unanswered filter #190

Merged
merged 2 commits into from
Jan 5, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ react-quiz-component is a ReactJS component allowing users to attempt a quiz.
- Allow Picture in Question
- Scoring System
- Shuffling Questions / Answers
- Timer Support
- Support Pause/Resume Timer
- Shows unanswered questions

## Installing

Expand Down Expand Up @@ -258,6 +261,24 @@ import { quiz } from './quiz';
<Quiz quiz={quiz} continueTillCorrect={true}/>
```


## Timer Feature

```js
import { quiz } from './quiz';
...
<Quiz quiz={quiz} timer={60}/>
```


## Pause/Resume Timer Feature

```js
import { quiz } from './quiz';
...
<Quiz quiz={quiz} timer={60} allowPauseTimer={true}/>
```

## Props

|Name|Type|Default|Required|Description|
Expand All @@ -272,6 +293,8 @@ import { quiz } from './quiz';
|continueTillCorrect|`boolean`|`false`|N|Continue to select an answer until it is correct|
|onQuestionSubmit|`function`|`null`|N|A user response for a question will be returned|
|disableSynopsis|`boolean`|`false`|N|Disable synopsis before quiz|
|timer|`number`|`false`|N|Sets timer in seconds|
|allowPauseTimer|`boolean`|`false`|N|Pause/Resume timer|

## Contribution

Expand Down
2 changes: 2 additions & 0 deletions src/docs/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ function App() {
onComplete={setQuizResult}
onQuestionSubmit={(obj) => console.log('user question results:', obj)}
disableSynopsis
timer={60}
allowPauseTimer
/>
</div>
);
Expand Down
231 changes: 184 additions & 47 deletions src/lib/Core.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Explanation from './core-components/Explanation';
function Core({
questions, appLocale, showDefaultResult, onComplete, customResultPage,
showInstantFeedback, continueTillCorrect, revealAnswerOnSubmit, allowNavigation,
onQuestionSubmit,
onQuestionSubmit, timer, allowPauseTimer,
}) {
const [incorrectAnswer, setIncorrectAnswer] = useState(false);
const [isCorrect, setIsCorrect] = useState(false);
Expand All @@ -20,6 +20,7 @@ function Core({
const [buttons, setButtons] = useState({});
const [correct, setCorrect] = useState([]);
const [incorrect, setIncorrect] = useState([]);
const [unanswered, setUnanswered] = useState([]);
const [userInput, setUserInput] = useState([]);
const [filteredValue, setFilteredValue] = useState('all');
const [userAttempt, setUserAttempt] = useState(1);
Expand All @@ -30,6 +31,8 @@ function Core({
const [correctPoints, setCorrectPoints] = useState(0);
const [activeQuestion, setActiveQuestion] = useState(questions[currentQuestionIndex]);
const [questionSummary, setQuestionSummary] = useState(undefined);
const [timeRemaining, setTimeRemaining] = useState(timer);
const [isRunning, setIsRunning] = useState(true);

useEffect(() => {
setShowDefaultResult(showDefaultResult !== undefined ? showDefaultResult : true);
Expand All @@ -47,6 +50,7 @@ function Core({

useEffect(() => {
if (endQuiz) {
setIsRunning(false);
let totalPointsTemp = 0;
let correctPointsTemp = 0;
for (let i = 0; i < questions.length; i += 1) {
Expand Down Expand Up @@ -122,12 +126,24 @@ function Core({
return answers.map((answer, index) => {
if (answerSelectionType === 'single') {
// correctAnswer - is string
answerBtnCorrectClassName = (`${index + 1}` === correctAnswer ? 'correct' : '');
answerBtnIncorrectClassName = (`${userInputIndex}` !== correctAnswer && `${index + 1}` === `${userInputIndex}` ? 'incorrect' : '');
answerBtnCorrectClassName = `${index + 1}` === correctAnswer ? 'correct' : '';
answerBtnIncorrectClassName = `${userInputIndex}` !== correctAnswer
&& `${index + 1}` === `${userInputIndex}` ? 'incorrect' : '';

if (userInputIndex === undefined && `${index + 1}` !== correctAnswer) {
answerBtnIncorrectClassName = ' unanswered';
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
}
} else {
// correctAnswer - is array of numbers
answerBtnCorrectClassName = (correctAnswer.includes(index + 1) ? 'correct' : '');
answerBtnIncorrectClassName = (!correctAnswer.includes(index + 1) && userInputIndex.includes(index + 1) ? 'incorrect' : '');
answerBtnCorrectClassName = correctAnswer.includes(index + 1)
? 'correct'
: '';
answerBtnIncorrectClassName = !correctAnswer.includes(index + 1)
&& userInputIndex?.includes(index + 1) ? 'incorrect' : '';

if (userInputIndex === undefined && !correctAnswer.includes(index + 1)) {
answerBtnIncorrectClassName = ' unanswered';
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
}
}

return (
Expand Down Expand Up @@ -184,25 +200,54 @@ function Core({

if (filteredValue !== 'all') {
if (filteredValue === 'correct') {
filteredQuestions = questions.filter((question, index) => correct.indexOf(index) !== -1);
filteredUserInput = userInput.filter((input, index) => correct.indexOf(index) !== -1);
} else {
filteredQuestions = questions.filter((question, index) => incorrect.indexOf(index) !== -1);
filteredUserInput = userInput.filter((input, index) => incorrect.indexOf(index) !== -1);
filteredQuestions = questions.filter(
(question, index) => correct.indexOf(index) !== -1,
);
filteredUserInput = userInput.filter(
(input, index) => correct.indexOf(index) !== -1,
);
} else if (filteredValue === 'incorrect') {
filteredQuestions = questions.filter(
(question, index) => incorrect.indexOf(index) !== -1,
);
filteredUserInput = userInput.filter(
(input, index) => incorrect.indexOf(index) !== -1,
);
} else if (filteredValue === 'unanswered') {
filteredQuestions = questions.filter(
(question, index) => unanswered.indexOf(index) !== -1,
);
filteredUserInput = userInput.filter(
(input, index) => unanswered.indexOf(index) !== -1,
);
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
}
}

return (filteredQuestions || questions).map((question, index) => {
const userInputIndex = filteredUserInput ? filteredUserInput[index] : userInput[index];
const userInputIndex = filteredUserInput
? filteredUserInput[index]
: userInput[index];

// Default single to avoid code breaking due to automatic version upgrade
const answerSelectionType = question.answerSelectionType || 'single';

return (
<div className="result-answer-wrapper" key={uuidv4()}>
<h3 dangerouslySetInnerHTML={rawMarkup(`Q${question.questionIndex}: ${question.question} ${appLocale.marksOfQuestion.replace('<marks>', question.point)}`)} />
{question.questionPic && <img src={question.questionPic} alt="question" />}
{renderTags(answerSelectionType, question.correctAnswer.length, question.segment)}
<h3
dangerouslySetInnerHTML={rawMarkup(
`Q${question.questionIndex}: ${
question.question
} ${appLocale.marksOfQuestion.replace('<marks>', question.point)}`,
)}
/>
{question.questionPic && (
<img src={question.questionPic} alt="question" />
)}
{renderTags(
answerSelectionType,
question.correctAnswer.length,
question.segment,
)}
<div className="result-answer">
{renderAnswerInResult(question, userInputIndex)}
</div>
Expand Down Expand Up @@ -292,6 +337,14 @@ function Core({
));
};

const getUnansweredQuestions = () => {
questions.forEach((question, index) => {
if (userInput[index] === undefined) {
setUnanswered((oldArray) => [...oldArray, index]);
}
});
};

const renderResult = () => (
<div className="card-body">
<h2>
Expand All @@ -313,48 +366,132 @@ function Core({
{renderQuizResultQuestions()}
</div>
);

useEffect(() => {
let countdown;

if (timer && isRunning && timeRemaining > 0) {
countdown = setInterval(() => {
setTimeRemaining((prevTime) => prevTime - 1);
}, 1000);
}
return () => timer && clearInterval(countdown);
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
}, [isRunning, timeRemaining, timer]);

const toggleTimer = () => {
setIsRunning(!isRunning);
};

const displayTime = (time) => {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
const seconds = time % 60;

return `${hours}:${minutes < 10 ? '0' : ''}${minutes}:${
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
seconds < 10 ? '0' : ''
}${seconds}`;
};

const handleTimeUp = () => {
setIsRunning(false);
setEndQuiz(true);
getUnansweredQuestions();
};

return (
<div className="questionWrapper">
{!endQuiz
&& (
{timer && !isRunning && (
<div>
Time Taken:
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
<b>{displayTime(timer - timeRemaining)}</b>
</div>
)}

{timer && isRunning && (
<div>
Time Remaining:
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
<b>{displayTime(timeRemaining)}</b>
</div>
)}
{timer && timeRemaining === 0 && isRunning && handleTimeUp()}

{!endQuiz && (
<div className="questionWrapperBody">
<div className="questionModal">
<InstantFeedback
question={activeQuestion}
showInstantFeedback={showInstantFeedback}
correctAnswer={isCorrect}
incorrectAnswer={incorrectAnswer}
onQuestionSubmit={onQuestionSubmit}
userAnswer={[...userInput].pop()}
/>
</div>
<div>
{`${appLocale.question} ${(currentQuestionIndex + 1)} / ${questions.length}:`}
</div>
<h3 dangerouslySetInnerHTML={rawMarkup(`${activeQuestion && activeQuestion.question} ${appLocale.marksOfQuestion.replace('<marks>', activeQuestion.point)}`)} />

{activeQuestion && activeQuestion.questionPic && <img src={activeQuestion.questionPic} alt="question" />}
{activeQuestion && renderTags(answerSelectionTypeState, activeQuestion.correctAnswer.length, activeQuestion.segment)}
{activeQuestion && renderAnswers(activeQuestion, buttons)}
{(showNextQuestionButton || allowNavigation)
&& (
<div className="questionBtnContainer">
{(allowNavigation && currentQuestionIndex > 0) && (
<button
onClick={() => nextQuestion(currentQuestionIndex - 2)}
className="prevQuestionBtn btn"
type="button"
>
{appLocale.prevQuestionBtn}
{`${appLocale.question} ${currentQuestionIndex + 1} / ${
questions.length
}:`}
<br />
{timer && allowPauseTimer && (
<button type="button" className="timerBtn" onClick={toggleTimer}>
{isRunning ? 'Pause' : 'Resume'}
wingkwong marked this conversation as resolved.
Show resolved Hide resolved
</button>
)}
<button onClick={() => nextQuestion(currentQuestionIndex)} className="nextQuestionBtn btn" type="button">
{appLocale.nextQuestionBtn}
</button>
</div>
{isRunning ? (
<>
<h3
dangerouslySetInnerHTML={rawMarkup(
`${
activeQuestion && activeQuestion.question
} ${appLocale.marksOfQuestion.replace(
'<marks>',
activeQuestion.point,
)}`,
)}
/>
{activeQuestion && activeQuestion.questionPic && (
<img src={activeQuestion.questionPic} alt="question" />
)}
{activeQuestion
&& renderTags(
answerSelectionTypeState,
activeQuestion.correctAnswer.length,
activeQuestion.segment,
)}
{activeQuestion && renderAnswers(activeQuestion, buttons)}
{(showNextQuestionButton || allowNavigation) && (
<div className="questionBtnContainer">
{allowNavigation && currentQuestionIndex > 0 && (
<button
onClick={() => nextQuestion(currentQuestionIndex - 2)}
className="prevQuestionBtn btn"
type="button"
>
{appLocale.prevQuestionBtn}
</button>
)}

<button
onClick={() => nextQuestion(currentQuestionIndex)}
className="nextQuestionBtn btn"
type="button"
>
{appLocale.nextQuestionBtn}
</button>

<div className="questionModal">
<InstantFeedback
question={activeQuestion}
showInstantFeedback={showInstantFeedback}
correctAnswer={isCorrect}
incorrectAnswer={incorrectAnswer}
onQuestionSubmit={onQuestionSubmit}
userAnswer={[...userInput].pop()}
/>
</div>
</div>
)}
</>
) : (
<span className="timerPauseScreen dark:text-white text-black">
<br />
<br />
{appLocale.pauseScreenDisplay}
</span>
)}
</div>
)}
)}
{endQuiz && showDefaultResultState && customResultPage === undefined
&& renderResult()}
{endQuiz && !showDefaultResultState && customResultPage !== undefined
Expand Down
2 changes: 2 additions & 0 deletions src/lib/Locale.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ const defaultLocale = {
resultFilterAll: 'All',
resultFilterCorrect: 'Correct',
resultFilterIncorrect: 'Incorrect',
resultFilterUnanswered: 'Unanswered',
nextQuestionBtn: 'Next',
prevQuestionBtn: 'Prev',
resultPageHeaderText: 'You have completed the quiz. You got <correctIndexLength> out of <questionLength> questions.',
resultPagePoint: 'You scored <correctPoints> out of <totalPoints>.',
pauseScreenDisplay: 'Test is paused. Clicked the Resume button to continue',
singleSelectionTagText: 'Single Selection',
multipleSelectionTagText: 'Multiple Selection',
pickNumberOfSelection: 'Pick <numberOfSelection>',
Expand Down
4 changes: 4 additions & 0 deletions src/lib/Quiz.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ function Quiz({
allowNavigation,
onQuestionSubmit,
disableSynopsis,
timer,
allowPauseTimer,
}) {
const [start, setStart] = useState(false);
const [questions, setQuestions] = useState(quiz.questions);
Expand Down Expand Up @@ -208,6 +210,8 @@ function Quiz({
allowNavigation={allowNavigation}
appLocale={appLocale}
onQuestionSubmit={onQuestionSubmit}
timer={timer}
allowPauseTimer={allowPauseTimer}
/>
)}
</div>
Expand Down
Loading