From 099ff6edc6edf12e75225ca4878f1e039dc57ac4 Mon Sep 17 00:00:00 2001 From: Chibuike Nwachukwu Date: Thu, 4 Jan 2024 02:03:53 +0100 Subject: [PATCH 1/2] Adds timer feature with pause/resume and unanswered filter --- README.md | 23 ++ src/docs/index.jsx | 2 + src/lib/Core.jsx | 231 +++++++++++++++---- src/lib/Locale.jsx | 2 + src/lib/Quiz.jsx | 4 + src/lib/core-components/QuizResultFilter.jsx | 27 ++- src/lib/styles.css | 23 ++ 7 files changed, 262 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 419139a..b04d8b9 100644 --- a/README.md +++ b/README.md @@ -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 @@ -258,6 +261,24 @@ import { quiz } from './quiz'; ``` + +## Timer Feature + +```js +import { quiz } from './quiz'; +... + +``` + + +## Pause/Resume Timer Feature + +```js +import { quiz } from './quiz'; +... + +``` + ## Props |Name|Type|Default|Required|Description| @@ -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 diff --git a/src/docs/index.jsx b/src/docs/index.jsx index db5b494..72409b8 100644 --- a/src/docs/index.jsx +++ b/src/docs/index.jsx @@ -20,6 +20,8 @@ function App() { onComplete={setQuizResult} onQuestionSubmit={(obj) => console.log('user question results:', obj)} disableSynopsis + timer={60} + allowPauseTimer /> ); diff --git a/src/lib/Core.jsx b/src/lib/Core.jsx index a7b808d..83b6289 100644 --- a/src/lib/Core.jsx +++ b/src/lib/Core.jsx @@ -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); @@ -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); @@ -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); @@ -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) { @@ -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'; + } } 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'; + } } return ( @@ -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, + ); } } 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 (
-

', question.point)}`)} /> - {question.questionPic && question} - {renderTags(answerSelectionType, question.correctAnswer.length, question.segment)} +

', question.point)}`, + )} + /> + {question.questionPic && ( + question + )} + {renderTags( + answerSelectionType, + question.correctAnswer.length, + question.segment, + )}
{renderAnswerInResult(question, userInputIndex)}
@@ -292,6 +337,14 @@ function Core({ )); }; + const getUnansweredQuestions = () => { + questions.forEach((question, index) => { + if (userInput[index] === undefined) { + setUnanswered((oldArray) => [...oldArray, index]); + } + }); + }; + const renderResult = () => (

@@ -313,48 +366,132 @@ function Core({ {renderQuizResultQuestions()}

); + + useEffect(() => { + let countdown; + + if (timer && isRunning && timeRemaining > 0) { + countdown = setInterval(() => { + setTimeRemaining((prevTime) => prevTime - 1); + }, 1000); + } + return () => timer && clearInterval(countdown); + }, [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}:${ + seconds < 10 ? '0' : '' + }${seconds}`; + }; + + const handleTimeUp = () => { + setIsRunning(false); + setEndQuiz(true); + getUnansweredQuestions(); + }; + return (
- {!endQuiz - && ( + {timer && !isRunning && ( +
+ Time Taken: + {displayTime(timer - timeRemaining)} +
+ )} + + {timer && isRunning && ( +
+ Time Remaining: + {displayTime(timeRemaining)} +
+ )} + {timer && timeRemaining === 0 && isRunning && handleTimeUp()} + + {!endQuiz && (
-
- -
- {`${appLocale.question} ${(currentQuestionIndex + 1)} / ${questions.length}:`} -
-

', activeQuestion.point)}`)} /> - - {activeQuestion && activeQuestion.questionPic && question} - {activeQuestion && renderTags(answerSelectionTypeState, activeQuestion.correctAnswer.length, activeQuestion.segment)} - {activeQuestion && renderAnswers(activeQuestion, buttons)} - {(showNextQuestionButton || allowNavigation) - && ( -
- {(allowNavigation && currentQuestionIndex > 0) && ( - )} -
+ {isRunning ? ( + <> +

', + activeQuestion.point, + )}`, + )} + /> + {activeQuestion && activeQuestion.questionPic && ( + question + )} + {activeQuestion + && renderTags( + answerSelectionTypeState, + activeQuestion.correctAnswer.length, + activeQuestion.segment, + )} + {activeQuestion && renderAnswers(activeQuestion, buttons)} + {(showNextQuestionButton || allowNavigation) && ( +
+ {allowNavigation && currentQuestionIndex > 0 && ( + + )} + + + +
+ +
+
+ )} + + ) : ( + +
+
+ {appLocale.pauseScreenDisplay} +
)}

- )} + )} {endQuiz && showDefaultResultState && customResultPage === undefined && renderResult()} {endQuiz && !showDefaultResultState && customResultPage !== undefined diff --git a/src/lib/Locale.jsx b/src/lib/Locale.jsx index d78111b..27a3ed3 100644 --- a/src/lib/Locale.jsx +++ b/src/lib/Locale.jsx @@ -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 out of questions.', resultPagePoint: 'You scored out of .', + pauseScreenDisplay: 'Test is paused. Clicked the Resume button to continue', singleSelectionTagText: 'Single Selection', multipleSelectionTagText: 'Multiple Selection', pickNumberOfSelection: 'Pick ', diff --git a/src/lib/Quiz.jsx b/src/lib/Quiz.jsx index 82e167d..6cf885e 100644 --- a/src/lib/Quiz.jsx +++ b/src/lib/Quiz.jsx @@ -16,6 +16,8 @@ function Quiz({ allowNavigation, onQuestionSubmit, disableSynopsis, + timer, + allowPauseTimer, }) { const [start, setStart] = useState(false); const [questions, setQuestions] = useState(quiz.questions); @@ -208,6 +210,8 @@ function Quiz({ allowNavigation={allowNavigation} appLocale={appLocale} onQuestionSubmit={onQuestionSubmit} + timer={timer} + allowPauseTimer={allowPauseTimer} /> )}
diff --git a/src/lib/core-components/QuizResultFilter.jsx b/src/lib/core-components/QuizResultFilter.jsx index d1ad32f..3866d38 100644 --- a/src/lib/core-components/QuizResultFilter.jsx +++ b/src/lib/core-components/QuizResultFilter.jsx @@ -55,7 +55,9 @@ function QuizResultFilter({ filteredValue, handleChange, appLocale }) { aria-labelledby="quiz-filter" >
handleOptionClick('all')} onKeyDown={(e) => { if (e.key === 'Enter') { @@ -68,7 +70,9 @@ function QuizResultFilter({ filteredValue, handleChange, appLocale }) { {appLocale.resultFilterAll}
handleOptionClick('correct')} onKeyDown={(e) => { if (e.key === 'Enter') { @@ -81,7 +85,9 @@ function QuizResultFilter({ filteredValue, handleChange, appLocale }) { {appLocale.resultFilterCorrect}
handleOptionClick('incorrect')} onKeyDown={(e) => { if (e.key === 'Enter') { @@ -93,6 +99,21 @@ function QuizResultFilter({ filteredValue, handleChange, appLocale }) { > {appLocale.resultFilterIncorrect}
+
handleOptionClick('unanswered')} + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleOptionClick('unanswered'); + } + }} + role="menuitem" + tabIndex={0} + > + {appLocale.resultFilterUnanswered} +

)} diff --git a/src/lib/styles.css b/src/lib/styles.css index 1edf030..2997087 100644 --- a/src/lib/styles.css +++ b/src/lib/styles.css @@ -41,6 +41,24 @@ color: white; } +.timerBtn { + background: green; + border: 0 !important; + width: 80px; + color: #fff; + padding: 5px; + margin-bottom: 10px; + border-radius: 10px; + position: relative; + float: right; + cursor: pointer; +} + +.timerPauseScreen { + font-size: 30px; +} + + .react-quiz-container .questionModal .alert { padding: 20px; margin-bottom: 21px; @@ -58,6 +76,11 @@ color: white; } +.react-quiz-container .unanswered { + background: grey; + color: white; +} + .react-quiz-container .questionWrapper img { width: 100%; } From d3f36549d4b280350c985ee776c467c06e5deecc Mon Sep 17 00:00:00 2001 From: Chibuike Nwachukwu Date: Thu, 4 Jan 2024 15:05:13 +0100 Subject: [PATCH 2/2] Resolves issues --- src/lib/Core.jsx | 66 ++++++++++++++++++++-------------------------- src/lib/Locale.jsx | 4 +++ src/lib/Quiz.jsx | 10 +++++++ src/lib/styles.css | 2 +- 4 files changed, 44 insertions(+), 38 deletions(-) diff --git a/src/lib/Core.jsx b/src/lib/Core.jsx index 83b6289..afa32db 100644 --- a/src/lib/Core.jsx +++ b/src/lib/Core.jsx @@ -131,7 +131,7 @@ function Core({ && `${index + 1}` === `${userInputIndex}` ? 'incorrect' : ''; if (userInputIndex === undefined && `${index + 1}` !== correctAnswer) { - answerBtnIncorrectClassName = ' unanswered'; + answerBtnIncorrectClassName = 'unanswered'; } } else { // correctAnswer - is array of numbers @@ -142,7 +142,7 @@ function Core({ && userInputIndex?.includes(index + 1) ? 'incorrect' : ''; if (userInputIndex === undefined && !correctAnswer.includes(index + 1)) { - answerBtnIncorrectClassName = ' unanswered'; + answerBtnIncorrectClassName = 'unanswered'; } } @@ -199,28 +199,18 @@ function Core({ let filteredUserInput; if (filteredValue !== 'all') { + let targetQuestions = unanswered; if (filteredValue === 'correct') { - filteredQuestions = questions.filter( - (question, index) => correct.indexOf(index) !== -1, - ); - filteredUserInput = userInput.filter( - (input, index) => correct.indexOf(index) !== -1, - ); + targetQuestions = correct; } 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, - ); + targetQuestions = incorrect; } + filteredQuestions = questions.filter( + (_, index) => targetQuestions.indexOf(index) !== -1, + ); + filteredUserInput = userInput.filter( + (_, index) => targetQuestions.indexOf(index) !== -1, + ); } return (filteredQuestions || questions).map((question, index) => { @@ -382,13 +372,14 @@ function Core({ setIsRunning(!isRunning); }; + const formatTime = (time) => (time < 10 ? '0' : ''); 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}:${ - seconds < 10 ? '0' : '' + return `${formatTime(hours)}${hours}:${formatTime(minutes)}${minutes}:${ + formatTime(seconds) }${seconds}`; }; @@ -402,14 +393,16 @@ function Core({
{timer && !isRunning && (
- Time Taken: + {appLocale.timerTimeTaken} + : {displayTime(timer - timeRemaining)}
)} {timer && isRunning && (
- Time Remaining: + {appLocale.timerTimeRemaining} + : {displayTime(timeRemaining)}
)} @@ -424,7 +417,7 @@ function Core({
{timer && allowPauseTimer && ( )}
@@ -449,6 +442,16 @@ function Core({ activeQuestion.correctAnswer.length, activeQuestion.segment, )} +
+ +
{activeQuestion && renderAnswers(activeQuestion, buttons)} {(showNextQuestionButton || allowNavigation) && (
@@ -469,17 +472,6 @@ function Core({ > {appLocale.nextQuestionBtn} - -
- -
)} diff --git a/src/lib/Locale.jsx b/src/lib/Locale.jsx index 27a3ed3..e6f213e 100644 --- a/src/lib/Locale.jsx +++ b/src/lib/Locale.jsx @@ -11,6 +11,10 @@ const defaultLocale = { resultPageHeaderText: 'You have completed the quiz. You got out of questions.', resultPagePoint: 'You scored out of .', pauseScreenDisplay: 'Test is paused. Clicked the Resume button to continue', + timerTimeRemaining: 'Time Remaining', + timerTimeTaken: 'Time Taken', + pauseScreenPause: 'Pause', + pauseScreenResume: 'Resume', singleSelectionTagText: 'Single Selection', multipleSelectionTagText: 'Multiple Selection', pickNumberOfSelection: 'Pick ', diff --git a/src/lib/Quiz.jsx b/src/lib/Quiz.jsx index 6cf885e..e73367c 100644 --- a/src/lib/Quiz.jsx +++ b/src/lib/Quiz.jsx @@ -98,6 +98,16 @@ function Quiz({ return false; } + if ((timer && typeof timer !== 'number') || (timer < 1)) { + console.error(timer && typeof timer !== 'number' ? 'timer must be a number' : 'timer must be a number greater than 0'); + return false; + } + + if (allowPauseTimer && typeof allowPauseTimer !== 'boolean') { + console.error('allowPauseTimer must be a Boolean'); + return false; + } + for (let i = 0; i < questions.length; i += 1) { const { question, diff --git a/src/lib/styles.css b/src/lib/styles.css index 2997087..442ccb3 100644 --- a/src/lib/styles.css +++ b/src/lib/styles.css @@ -47,7 +47,7 @@ width: 80px; color: #fff; padding: 5px; - margin-bottom: 10px; + top: -35px; border-radius: 10px; position: relative; float: right;