diff --git a/client/package-lock.json b/client/package-lock.json index b1dbdf0..8e24d67 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -19,6 +19,7 @@ "@mui/material": "^7.0.2", "@types/uuid": "^9.0.7", "axios": "^1.8.1", + "bootstrap": "^5.3.4", "dompurify": "^3.2.5", "esbuild": "^0.25.2", "gift-pegjs": "^2.0.0-beta.1", @@ -30,6 +31,7 @@ "nanoid": "^5.1.5", "qrcode.react": "^4.2.0", "react": "^18.3.1", + "react-bootstrap": "^2.10.9", "react-dom": "^18.3.1", "react-modal": "^3.16.3", "react-router-dom": "^6.26.2", @@ -3825,6 +3827,20 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@react-aria/ssr": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/@react-aria/ssr/-/ssr-3.9.7.tgz", + "integrity": "sha512-GQygZaGlmYjmYM+tiNBA5C6acmiDWF52Nqd40bBp0Znk4M4hP+LTmI0lpI1BuKMw45T8RIhrAsICIfKwZvi2Gg==", + "dependencies": { + "@swc/helpers": "^0.5.0" + }, + "engines": { + "node": ">= 12" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" + } + }, "node_modules/@remix-run/router": { "version": "1.23.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", @@ -3834,6 +3850,56 @@ "node": ">=14.0.0" } }, + "node_modules/@restart/hooks": { + "version": "0.4.16", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.4.16.tgz", + "integrity": "sha512-f7aCv7c+nU/3mF7NWLtVVr0Ra80RqsO89hO72r+Y/nvQr5+q0UFGkocElTH6MJApvReVh6JHUFYn2cw1WdHF3w==", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@restart/ui/-/ui-1.9.4.tgz", + "integrity": "sha512-N4C7haUc3vn4LTwVUPlkJN8Ach/+yIMvRuTVIhjilNHqegY60SGLrzud6errOMNJwSnmYFnt1J0H/k8FE3A4KA==", + "dependencies": { + "@babel/runtime": "^7.26.0", + "@popperjs/core": "^2.11.8", + "@react-aria/ssr": "^3.5.0", + "@restart/hooks": "^0.5.0", + "@types/warning": "^3.0.3", + "dequal": "^2.0.3", + "dom-helpers": "^5.2.0", + "uncontrollable": "^8.0.4", + "warning": "^4.0.3" + }, + "peerDependencies": { + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + } + }, + "node_modules/@restart/ui/node_modules/@restart/hooks": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@restart/hooks/-/hooks-0.5.1.tgz", + "integrity": "sha512-EMoH04NHS1pbn07iLTjIjgttuqb7qu4+/EyhAx27MHpoENcB2ZdSsLTNxmKD+WEPnZigo62Qc8zjGnNxoSE/5Q==", + "dependencies": { + "dequal": "^2.0.3" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@restart/ui/node_modules/uncontrollable": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-8.0.4.tgz", + "integrity": "sha512-ulRWYWHvscPFc0QQXvyJjY6LIXU56f0h8pQFvhxiKk5V1fcI8gp9Ht9leVAhrVjzqMw0BgjspBINx9r6oyJUvQ==", + "peerDependencies": { + "react": ">=16.14.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.43.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz", @@ -4298,6 +4364,14 @@ "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", "devOptional": true }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@swc/types": { "version": "0.1.21", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.21.tgz", @@ -4730,6 +4804,11 @@ "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", "license": "MIT" }, + "node_modules/@types/warning": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/warning/-/warning-3.0.3.tgz", + "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -5506,6 +5585,24 @@ "devOptional": true, "license": "MIT" }, + "node_modules/bootstrap": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.4.tgz", + "integrity": "sha512-q2oK3ZPDTa5I44FTyY3H76+SDTJREvOBxtX1HNLHcxMni50jMvUtOh+dgFdgpsAHtJ9bfNAWr6d6VezJHJ/7tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/base64-arraybuffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", @@ -5798,6 +5895,11 @@ "dev": true, "license": "MIT" }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -7987,6 +8089,14 @@ "node": ">= 0.4" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -11165,6 +11275,23 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types-extra": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/prop-types-extra/-/prop-types-extra-1.1.1.tgz", + "integrity": "sha512-59+AHNnHYCdiC+vMwY52WmvP5dM3QLeoumYuEyceQDi9aEhtwN9zIQ2ZNo25sMyXnbh32h+P1ezDsUpUH3JAew==", + "dependencies": { + "react-is": "^16.3.2", + "warning": "^4.0.0" + }, + "peerDependencies": { + "react": ">=0.14.0" + } + }, + "node_modules/prop-types-extra/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/prop-types/node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -11271,6 +11398,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-bootstrap": { + "version": "2.10.9", + "resolved": "https://registry.npmjs.org/react-bootstrap/-/react-bootstrap-2.10.9.tgz", + "integrity": "sha512-TJUCuHcxdgYpOqeWmRApM/Dy0+hVsxNRFvq2aRFQuxhNi/+ivOxC5OdWIeHS3agxvzJ4Ev4nDw2ZdBl9ymd/JQ==", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@restart/hooks": "^0.4.9", + "@restart/ui": "^1.9.4", + "@types/prop-types": "^15.7.12", + "@types/react-transition-group": "^4.4.6", + "classnames": "^2.3.2", + "dom-helpers": "^5.2.1", + "invariant": "^2.2.4", + "prop-types": "^15.8.1", + "prop-types-extra": "^1.1.0", + "react-transition-group": "^4.4.5", + "uncontrollable": "^7.2.1", + "warning": "^4.0.3" + }, + "peerDependencies": { + "@types/react": ">=16.14.8", + "react": ">=16.14.0", + "react-dom": ">=16.14.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -12578,6 +12735,11 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -12744,6 +12906,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncontrollable": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/uncontrollable/-/uncontrollable-7.2.1.tgz", + "integrity": "sha512-svtcfoTADIB0nT9nltgjujTi7BzVmwjZClOmskKu/E8FW9BXzg9os8OLr4f8Dlnk0rYWJIWr4wv9eKUXiQvQwQ==", + "dependencies": { + "@babel/runtime": "^7.6.3", + "@types/react": ">=16.9.11", + "invariant": "^2.2.4", + "react-lifecycles-compat": "^3.0.4" + }, + "peerDependencies": { + "react": ">=15.0.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/client/package.json b/client/package.json index e021132..9fef165 100644 --- a/client/package.json +++ b/client/package.json @@ -23,6 +23,7 @@ "@mui/material": "^7.0.2", "@types/uuid": "^9.0.7", "axios": "^1.8.1", + "bootstrap": "^5.3.4", "dompurify": "^3.2.5", "esbuild": "^0.25.2", "gift-pegjs": "^2.0.0-beta.1", @@ -34,6 +35,7 @@ "nanoid": "^5.1.5", "qrcode.react": "^4.2.0", "react": "^18.3.1", + "react-bootstrap": "^2.10.9", "react-dom": "^18.3.1", "react-modal": "^3.16.3", "react-router-dom": "^6.26.2", diff --git a/client/src/Types/StudentType.tsx b/client/src/Types/StudentType.tsx index 41a4a63..872bdee 100644 --- a/client/src/Types/StudentType.tsx +++ b/client/src/Types/StudentType.tsx @@ -11,4 +11,5 @@ export interface StudentType { id: string; room?: string; answers: Answer[]; + isActive?: boolean; } diff --git a/client/src/__tests__/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay.test.tsx b/client/src/__tests__/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay.test.tsx index 45e9b0a..415524e 100644 --- a/client/src/__tests__/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay.test.tsx +++ b/client/src/__tests__/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay.test.tsx @@ -209,5 +209,33 @@ describe('MultipleChoiceQuestionDisplay', () => { expect(wrongAnswer1?.textContent).not.toContain('❌'); }); + it('calculates and displays pick rates correctly when showResults is true', () => { + const question = parse(`::MCQ:: What is 2+2? { + =Four + ~Three + ~Five + }`)[0] as MultipleChoiceQuestion; + + const mockStudents = [ + { id: '1', name: 'Alice', answers: [{ idQuestion: 1, answer: ['Four'], isCorrect: true }] }, + { id: '2', name: 'Bob', answers: [{ idQuestion: 1, answer: ['Three'], isCorrect: false }] }, + { id: '3', name: 'Charlie', answers: [{ idQuestion: 1, answer: ['Four'], isCorrect: true }] } + ]; + + render( + + ); + + // Expect pick rate for "Four" to be 2/3 + expect(screen.getByText('✅2/3 (66.7%)')).toBeInTheDocument(); + + // Expect pick rate for "Three" to be 1/3 + expect(screen.getByText('❌1/3 (33.3%)')).toBeInTheDocument(); + }); + }); diff --git a/client/src/__tests__/components/QuestionsDisplay/NumericalQuestionDisplay/NumericalQuestionDisplay.test.tsx b/client/src/__tests__/components/QuestionsDisplay/NumericalQuestionDisplay/NumericalQuestionDisplay.test.tsx index 5c32547..697ddca 100644 --- a/client/src/__tests__/components/QuestionsDisplay/NumericalQuestionDisplay/NumericalQuestionDisplay.test.tsx +++ b/client/src/__tests__/components/QuestionsDisplay/NumericalQuestionDisplay/NumericalQuestionDisplay.test.tsx @@ -81,4 +81,37 @@ describe('NumericalQuestion Component', () => { expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith([7]); mockHandleOnSubmitAnswer.mockClear(); }); + + it('calculates and displays correct answer rate when showResults is true', () => { + const mockStudents = [ + { + id: '1', + name: 'Alice', + answers: [{ idQuestion: 1, answer: [7], isCorrect: true }] + }, + { + id: '2', + name: 'Bob', + answers: [{ idQuestion: 1, answer: [3], isCorrect: false }] + }, + { + id: '3', + name: 'Charlie', + answers: [{ idQuestion: 1, answer: [6], isCorrect: true }] + } + ]; + + render( + + + + ); + + expect(screen.getByText('Taux de réponse correcte: 2/3')).toBeInTheDocument(); + expect(screen.getByText('66.7%')).toBeInTheDocument(); + }); }); diff --git a/client/src/__tests__/components/QuestionsDisplay/Question.test.tsx b/client/src/__tests__/components/QuestionsDisplay/Question.test.tsx index 142e563..83afa57 100644 --- a/client/src/__tests__/components/QuestionsDisplay/Question.test.tsx +++ b/client/src/__tests__/components/QuestionsDisplay/Question.test.tsx @@ -25,8 +25,8 @@ describe('Questions Component', () => { showAnswer: false }; - const renderComponent = (question: Question) => { - render(); + const renderComponent = (question: Question, showAnswerToggle = false) => { + render(); }; // describe('question type parsing', () => { @@ -122,6 +122,11 @@ describe('Questions Component', () => { expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(['User Input']); }); + + it('shows "Afficher les résultats" toggle when showAnswerToggle is true', () => { + renderComponent(sampleTrueFalseQuestion, true); + expect(screen.getByText('Afficher les résultats')).toBeInTheDocument(); + }); }); diff --git a/client/src/__tests__/components/QuestionsDisplay/ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay.test.tsx b/client/src/__tests__/components/QuestionsDisplay/ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay.test.tsx index 57e9da5..687f6a4 100644 --- a/client/src/__tests__/components/QuestionsDisplay/ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay.test.tsx +++ b/client/src/__tests__/components/QuestionsDisplay/ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, within } from '@testing-library/react'; import '@testing-library/jest-dom'; import { parse, ShortAnswerQuestion } from 'gift-pegjs'; import ShortAnswerQuestionDisplay from 'src/components/QuestionsDisplay/ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay'; +import { MemoryRouter } from 'react-router-dom'; describe('ShortAnswerQuestion Component', () => { const mockHandleSubmitAnswer = jest.fn(); @@ -64,4 +65,54 @@ describe('ShortAnswerQuestion Component', () => { expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(['User Input']); mockHandleSubmitAnswer.mockClear(); }); + + + it('calculates and displays correct answer rate when showResults is true', () => { + const mockStudents = [ + { + id: '1', + name: 'Alice', + answers: [{ idQuestion: 1, answer: ['Paris'], isCorrect: true }] + }, + { + id: '2', + name: 'Bob', + answers: [{ idQuestion: 1, answer: ['Lyon'], isCorrect: false }] + }, + { + id: '3', + name: 'Charlie', + answers: [{ idQuestion: 1, answer: ['Paris'], isCorrect: true }] + } + ]; + + + const question: ShortAnswerQuestion = { + id: '1', + type: 'Short', + hasEmbeddedAnswers: false, + formattedStem: { + text: 'What is the capital of France?', + format: 'html' + }, + choices: [{ text: 'Paris', isCorrect: true }], + formattedGlobalFeedback: { + text: '', + format: 'html' + } + }; + + render( + + + + ); + + expect(screen.getByText('Taux de réponse correcte: 2/3')).toBeInTheDocument(); + expect(screen.getByText('66.7%')).toBeInTheDocument(); + }); }); diff --git a/client/src/__tests__/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay.test.tsx b/client/src/__tests__/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay.test.tsx index f79d1da..87ce9cb 100644 --- a/client/src/__tests__/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay.test.tsx +++ b/client/src/__tests__/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay.test.tsx @@ -134,4 +134,43 @@ describe('TrueFalseQuestion Component', () => { expect(wrongAnswer1).toBeInTheDocument(); expect(wrongAnswer1?.textContent).not.toContain('❌'); }); + + it('calculates and displays pick rates correctly when showResults is true', () => { + const mockStudents = [ + { + id: '1', + name: 'Alice', + answers: [{ idQuestion: 1, answer: [true], isCorrect: true }] + }, + { + id: '2', + name: 'Bob', + answers: [{ idQuestion: 1, answer: [false], isCorrect: false }] + } + ]; + + render( + + + + ); + + const pickRateDivs = screen.getAllByText((_, element) => + element !== null && + (element as HTMLElement).classList.contains('pick-rate') && + (element as HTMLElement).textContent!.includes('1/2') + ); + expect(pickRateDivs.length).toBe(2); + + const percentDivs = screen.getAllByText((_, element) => + element !== null && + (element as HTMLElement).classList.contains('pick-rate') && + (element as HTMLElement).textContent!.includes('50.0%') + ); + expect(percentDivs.length).toBe(2); + }); }); diff --git a/client/src/components/LiveResults/LiveResults.tsx b/client/src/components/LiveResults/LiveResults.tsx index f165e10..17ef726 100644 --- a/client/src/components/LiveResults/LiveResults.tsx +++ b/client/src/components/LiveResults/LiveResults.tsx @@ -1,5 +1,6 @@ // LiveResults.tsx import React, { useState } from 'react'; + import { Socket } from 'socket.io-client'; import { QuestionType } from '../../Types/QuestionType'; import './liveResult.css'; @@ -26,11 +27,9 @@ const LiveResults: React.FC = ({ questions, showSelectedQuesti const [showCorrectAnswers, setShowCorrectAnswers] = useState(false); return ( - -
-
Résultats du quiz
+
Résultats du quiz
Afficher les noms
} diff --git a/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody.tsx b/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody.tsx index a0c67f7..adaba37 100644 --- a/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody.tsx +++ b/client/src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody.tsx @@ -25,7 +25,12 @@ const LiveResultsTableFooter: React.FC = ({ return ( {students.map((student) => ( - + = ({ maxQuestions, showSelectedQuestion, }) => { + const [selectedQuestionIndex, setSelectedQuestionIndex] = useState(null); + + const handleQuestionClick = (index: number) => { + setSelectedQuestionIndex(index); + showSelectedQuestion(index); + }; return ( @@ -25,9 +31,10 @@ const LiveResultsTableHeader: React.FC = ({ cursor: 'pointer', borderStyle: 'solid', borderWidth: 1, - borderColor: 'rgba(224, 224, 224, 1)' + borderColor: 'rgba(224, 224, 224, 1)', + backgroundColor: selectedQuestionIndex === index ? '#dedede' : 'transparent' }} - onClick={() => showSelectedQuestion(index)} + onClick={() => handleQuestionClick(index)} >
{`Q${index + 1}`}
diff --git a/client/src/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay.tsx b/client/src/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay.tsx index d023a40..1d7ac9b 100644 --- a/client/src/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay.tsx +++ b/client/src/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay.tsx @@ -1,9 +1,10 @@ // MultipleChoiceQuestionDisplay.tsx -import React, { useEffect, useState } from 'react'; +import React, { useState, useEffect } from 'react'; import '../questionStyle.css'; import { Button } from '@mui/material'; import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate'; import { MultipleChoiceQuestion } from 'gift-pegjs'; +import { StudentType } from 'src/Types/StudentType'; import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom'; interface Props { @@ -11,33 +12,30 @@ interface Props { handleOnSubmitAnswer?: (answer: AnswerType) => void; showAnswer?: boolean; passedAnswer?: AnswerType; + students?: StudentType[]; + isDisplayOnly?: boolean; + showResults?: boolean; } const MultipleChoiceQuestionDisplay: React.FC = (props) => { - const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = props; - console.log('MultipleChoiceQuestionDisplay: passedAnswer', JSON.stringify(passedAnswer)); - + const { question, showAnswer, handleOnSubmitAnswer, students, showResults, passedAnswer } = props; const [answer, setAnswer] = useState(() => { if (passedAnswer && passedAnswer.length > 0) { return passedAnswer; } return []; }); + const [pickRates, setPickRates] = useState<{ percentages: number[], counts: number[], totalCount: number }>({ + percentages: [], + counts: [], + totalCount: 0 + }); let disableButton = false; if (handleOnSubmitAnswer === undefined) { disableButton = true; } - useEffect(() => { - console.log('MultipleChoiceQuestionDisplay: passedAnswer', JSON.stringify(passedAnswer)); - if (passedAnswer !== undefined) { - setAnswer(passedAnswer); - } else { - setAnswer([]); - } - }, [passedAnswer, question.id]); - const handleOnClickAnswer = (choice: string) => { setAnswer((prevAnswer) => { console.log(`handleOnClickAnswer -- setAnswer(): prevAnswer: ${prevAnswer}, choice: ${choice}`); @@ -58,74 +56,119 @@ const MultipleChoiceQuestionDisplay: React.FC = (props) => { } }); }; + + const calculatePickRates = () => { + if (!students || students.length === 0) { + setPickRates({ percentages: new Array(question.choices.length).fill(0), counts: new Array(question.choices.length).fill(0), totalCount: 0 }); + return; + } + + const rates: number[] = []; + const counts: number[] = []; + let totalResponses = 0; + + question.choices.forEach(choice => { + const choiceCount = students.filter(student => + student.answers.some(ans => + ans.idQuestion === Number(question.id) && ans.answer.includes(choice.formattedText.text) + ) + ).length; + totalResponses += choiceCount; + rates.push((choiceCount / students.length) * 100); + counts.push(choiceCount); + }); + + setPickRates({ percentages: rates, counts: counts, totalCount: totalResponses }); + }; + + useEffect(() => { + if (passedAnswer !== undefined) { + setAnswer(passedAnswer); + } else { + setAnswer([]); + calculatePickRates(); + } + }, [passedAnswer, students, question.id]); + const alpha = Array.from(Array(26)).map((_e, i) => i + 65); const alphabet = alpha.map((x) => String.fromCharCode(x)); - + return ( -
-
-
-
-
- {question.choices.map((choice, i) => { - console.log(`answer: ${answer}, choice: ${choice.formattedText.text}`); - const selected = answer.includes(choice.formattedText.text) ? 'selected' : ''; - return ( -
-
- {choice.formattedFeedback && showAnswer && ( -
-
-
- )} - + ); + })} +
+ {question.formattedGlobalFeedback && showAnswer && ( +
+
- ); - })} -
- {question.formattedGlobalFeedback && showAnswer && ( -
-
+ )} + + {!showAnswer && handleOnSubmitAnswer && ( + + )}
- )} - {!showAnswer && handleOnSubmitAnswer && ( - - )} +
); }; diff --git a/client/src/components/QuestionsDisplay/NumericalQuestionDisplay/NumericalQuestionDisplay.tsx b/client/src/components/QuestionsDisplay/NumericalQuestionDisplay/NumericalQuestionDisplay.tsx index be28f57..36f0a5a 100644 --- a/client/src/components/QuestionsDisplay/NumericalQuestionDisplay/NumericalQuestionDisplay.tsx +++ b/client/src/components/QuestionsDisplay/NumericalQuestionDisplay/NumericalQuestionDisplay.tsx @@ -1,32 +1,64 @@ // NumericalQuestion.tsx -import React, { useEffect, useState } from 'react'; +import React, { useState, useEffect } from 'react'; import '../questionStyle.css'; import { Button, TextField } from '@mui/material'; import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate'; import { NumericalQuestion, SimpleNumericalAnswer, RangeNumericalAnswer, HighLowNumericalAnswer } from 'gift-pegjs'; import { isSimpleNumericalAnswer, isRangeNumericalAnswer, isHighLowNumericalAnswer, isMultipleNumericalAnswer } from 'gift-pegjs/typeGuards'; +import { StudentType } from 'src/Types/StudentType'; import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom'; interface Props { question: NumericalQuestion; handleOnSubmitAnswer?: (answer: AnswerType) => void; showAnswer?: boolean; - passedAnswer?: AnswerType; + passedAnswer?: AnswerType; + students?: StudentType[]; + showResults?: boolean; } const NumericalQuestionDisplay: React.FC = (props) => { - const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = + const { question, showAnswer, handleOnSubmitAnswer, students, showResults, passedAnswer } = props; const [answer, setAnswer] = useState(passedAnswer || []); const correctAnswers = question.choices; let correctAnswer = ''; + const [correctAnswerRate, setCorrectAnswerRate] = useState(0); + const [submissionCounts, setSubmissionCounts] = useState({ + correctSubmissions: 0, + totalSubmissions: 0 + }); useEffect(() => { - if (passedAnswer !== null && passedAnswer !== undefined) { - setAnswer(passedAnswer); - } - }, [passedAnswer]); - + if (passedAnswer !== null && passedAnswer !== undefined) { + setAnswer(passedAnswer); + } + if (showResults && students) { + calculateCorrectAnswerRate(); + } + }, [passedAnswer, showResults, students]); + + const calculateCorrectAnswerRate = () => { + if (!students || students.length === 0) { + setSubmissionCounts({ correctSubmissions: 0, totalSubmissions: 0 }); + return; + } + + const totalSubmissions = students.length; + const correctSubmissions = students.filter(student => + student.answers.some(ans => + ans.idQuestion === Number(question.id) && ans.isCorrect + ) + ).length; + + setSubmissionCounts({ + correctSubmissions, + totalSubmissions + }); + + setCorrectAnswerRate((correctSubmissions / totalSubmissions) * 100); + }; + //const isSingleAnswer = correctAnswers.length === 1; if (isSimpleNumericalAnswer(correctAnswers[0])) { @@ -44,57 +76,78 @@ const NumericalQuestionDisplay: React.FC = (props) => { } return ( -
-
-
-
- {showAnswer ? ( - <> -
- La bonne réponse est: - {correctAnswer}
- - Votre réponse est: {answer.toString()} - - {question.formattedGlobalFeedback &&
-
-
} - - - ) : ( - <> -
- ) => { - setAnswer([e.target.valueAsNumber]); - }} - inputProps={{ 'data-testid': 'number-input' }} - /> -
- {question.formattedGlobalFeedback && showAnswer && ( -
-
+ <> +
+
+
+
+
- )} - {handleOnSubmitAnswer && ( - - )} - - )} -
+ {showAnswer ? ( + <> +
+ La bonne réponse est: + {correctAnswer}
+ + Votre réponse est: {answer.toString()} + + {question.formattedGlobalFeedback &&
+
+
} + + + ) : ( + <> +
+ ) => { + setAnswer([e.target.valueAsNumber]); + }} + inputProps={{ 'data-testid': 'number-input' }} + /> +
+ {question.formattedGlobalFeedback && showAnswer && ( +
+
+
+ )} + {handleOnSubmitAnswer && ( +
+ +
+ )} + + )} +
+ {showResults && ( +
+
+ Taux de réponse correcte: {submissionCounts.correctSubmissions}/{submissionCounts.totalSubmissions} +
+
+
+
+ {correctAnswerRate.toFixed(1)}% +
+
+
+ )} +
+
+ ); }; diff --git a/client/src/components/QuestionsDisplay/QuestionDisplay.tsx b/client/src/components/QuestionsDisplay/QuestionDisplay.tsx index af6e6d8..6b31ee2 100644 --- a/client/src/components/QuestionsDisplay/QuestionDisplay.tsx +++ b/client/src/components/QuestionsDisplay/QuestionDisplay.tsx @@ -1,6 +1,8 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Question } from 'gift-pegjs'; +import { FormControlLabel, Switch } from '@mui/material'; + import TrueFalseQuestionDisplay from './TrueFalseQuestionDisplay/TrueFalseQuestionDisplay'; import MultipleChoiceQuestionDisplay from './MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay'; import NumericalQuestionDisplay from './NumericalQuestionDisplay/NumericalQuestionDisplay'; @@ -8,10 +10,15 @@ import ShortAnswerQuestionDisplay from './ShortAnswerQuestionDisplay/ShortAnswer import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom'; // import useCheckMobileScreen from '../../services/useCheckMobileScreen'; +import { StudentType } from '../../Types/StudentType'; + interface QuestionProps { question: Question; handleOnSubmitAnswer?: (answer: AnswerType) => void; showAnswer?: boolean; + students?: StudentType[]; + showResults?: boolean; + showAnswerToggle?: boolean; answer?: AnswerType; } @@ -19,12 +26,16 @@ const QuestionDisplay: React.FC = ({ question, handleOnSubmitAnswer, showAnswer, + showAnswerToggle = false, + students, answer, }) => { // const isMobile = useCheckMobileScreen(); // const imgWidth = useMemo(() => { // return isMobile ? '100%' : '20%'; // }, [isMobile]); + + const [showResults, setShowResults] = useState(false); let questionTypeComponent = null; switch (question?.type) { @@ -34,6 +45,8 @@ const QuestionDisplay: React.FC = ({ question={question} handleOnSubmitAnswer={handleOnSubmitAnswer} showAnswer={showAnswer} + students={students} + showResults={showResults} passedAnswer={answer} /> ); @@ -45,6 +58,8 @@ const QuestionDisplay: React.FC = ({ question={question} handleOnSubmitAnswer={handleOnSubmitAnswer} showAnswer={showAnswer} + students={students} + showResults={showResults} passedAnswer={answer} /> ); @@ -57,7 +72,8 @@ const QuestionDisplay: React.FC = ({ handleOnSubmitAnswer={handleOnSubmitAnswer} showAnswer={showAnswer} passedAnswer={answer} - + students={students} + showResults={showResults} /> ); } @@ -68,21 +84,39 @@ const QuestionDisplay: React.FC = ({ question={question} handleOnSubmitAnswer={handleOnSubmitAnswer} showAnswer={showAnswer} + students={students} + showResults={showResults} passedAnswer={answer} /> ); break; } return ( -
- {questionTypeComponent ? ( - <> - {questionTypeComponent} - - ) : ( -
Question de type inconnue
+ <> + {showAnswerToggle && ( + Afficher les résultats
} + control={ + ) => + setShowResults(e.target.checked) + } + /> + } + /> )} -
+ +
+ {questionTypeComponent ? ( + <> + {questionTypeComponent} + + ) : ( +
Question de type inconnue
+ )} +
+ ); }; diff --git a/client/src/components/QuestionsDisplay/ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay.tsx b/client/src/components/QuestionsDisplay/ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay.tsx index 4b15e4d..3310169 100644 --- a/client/src/components/QuestionsDisplay/ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay.tsx +++ b/client/src/components/QuestionsDisplay/ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay.tsx @@ -3,6 +3,7 @@ import '../questionStyle.css'; import { Button, TextField } from '@mui/material'; import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate'; import { ShortAnswerQuestion } from 'gift-pegjs'; +import { StudentType } from 'src/Types/StudentType'; import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom'; interface Props { @@ -10,76 +11,129 @@ interface Props { handleOnSubmitAnswer?: (answer: AnswerType) => void; showAnswer?: boolean; passedAnswer?: AnswerType; - + students?: StudentType[]; + showResults?: boolean; } const ShortAnswerQuestionDisplay: React.FC = (props) => { - - const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = props; + const { question, showAnswer, handleOnSubmitAnswer, students, showResults, passedAnswer } = props; const [answer, setAnswer] = useState(passedAnswer || []); - + const [correctAnswerRate, setCorrectAnswerRate] = useState(0); + const [submissionCounts, setSubmissionCounts] = useState({ + correctSubmissions: 0, + totalSubmissions: 0 + }); + useEffect(() => { - if (passedAnswer !== undefined) { - setAnswer(passedAnswer); - } - }, [passedAnswer]); - console.log("Answer" , answer); + if (passedAnswer !== undefined) { + setAnswer(passedAnswer); + } + + if (showResults && students) { + calculateCorrectAnswerRate(); + } + + }, [passedAnswer, showResults, students, answer]); + console.log("Answer", answer); + + const calculateCorrectAnswerRate = () => { + if (!students || students.length === 0) { + setSubmissionCounts({ correctSubmissions: 0, totalSubmissions: 0 }); + return; + } + + const totalSubmissions = students.length; + const correctSubmissions = students.filter(student => + student.answers.some(ans => + ans.idQuestion === Number(question.id) && ans.isCorrect + ) + ).length; + + setSubmissionCounts({ + correctSubmissions, + totalSubmissions + }); + + setCorrectAnswerRate((correctSubmissions / totalSubmissions) * 100); + }; return ( -
-
-
-
- {showAnswer ? ( - <> -
- - La bonne réponse est: - - {question.choices.map((choice) => ( -
- {choice.text} + <> +
+
+
+
+
+
+ {showAnswer ? ( + <> +
+ + La bonne réponse est: + {question.choices.map((choice) => ( +
+ {choice.text} +
+ ))} +
+ + Votre réponse est: {answer} + +
+ {question.formattedGlobalFeedback && ( +
+
+
+ )} + + ) : ( + <> +
+ { + setAnswer([e.target.value]); + }} + disabled={showAnswer} + aria-label="short-answer-input" + /> +
+ {handleOnSubmitAnswer && ( +
+ +
+ )} + + )} +
+ {showResults && ( +
+
+ Taux de réponse correcte: {submissionCounts.correctSubmissions}/{submissionCounts.totalSubmissions} +
+
+
+
+ {correctAnswerRate.toFixed(1)}%
- ))} - - - Votre réponse est: {answer} - +
- {question.formattedGlobalFeedback &&
-
-
} - - ) : ( - <> -
- { - setAnswer([e.target.value]); - }} - disabled={showAnswer} - aria-label="short-answer-input" - /> -
- {handleOnSubmitAnswer && ( - - )} - - )} + )} +
+ ); }; diff --git a/client/src/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay.tsx b/client/src/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay.tsx index 7decbab..a961b6e 100644 --- a/client/src/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay.tsx +++ b/client/src/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay.tsx @@ -4,18 +4,27 @@ import '../questionStyle.css'; import { Button } from '@mui/material'; import { TrueFalseQuestion } from 'gift-pegjs'; import { FormattedTextTemplate } from 'src/components/GiftTemplate/templates/TextTypeTemplate'; +import { StudentType } from 'src/Types/StudentType'; import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom'; interface Props { question: TrueFalseQuestion; handleOnSubmitAnswer?: (answer: AnswerType) => void; showAnswer?: boolean; - passedAnswer?: AnswerType; + passedAnswer?: AnswerType; + students?: StudentType[]; + showResults?: boolean; } const TrueFalseQuestionDisplay: React.FC = (props) => { - const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = - props; + const { question, showAnswer, handleOnSubmitAnswer, students, passedAnswer, showResults } = props; + const [pickRates, setPickRates] = useState<{ trueRate: number, falseRate: number, trueCount: number, falseCount: number, totalCount: number }>({ + trueRate: 0, + falseRate: 0, + trueCount: 0, + falseCount: 0, + totalCount: 0 + }); const [answer, setAnswer] = useState(() => { @@ -31,75 +40,140 @@ const TrueFalseQuestionDisplay: React.FC = (props) => { disableButton = true; } + const handleOnClickAnswer = (choice: boolean) => { + setAnswer(choice); + }; + useEffect(() => { - console.log("passedAnswer", passedAnswer); if (passedAnswer && (passedAnswer[0] === true || passedAnswer[0] === false)) { setAnswer(passedAnswer[0]); } else { setAnswer(undefined); } - }, [passedAnswer, question.id]); - const handleOnClickAnswer = (choice: boolean) => { - setAnswer(choice); - }; + if (!passedAnswer && passedAnswer !== false) { + setAnswer(undefined); + calculatePickRates(); + } + }, [passedAnswer, question.id, students]); const selectedTrue = answer ? 'selected' : ''; const selectedFalse = answer !== undefined && !answer ? 'selected' : ''; + + // Calcul le pick rate de chaque réponse + const calculatePickRates = () => { + if (!students) { + setPickRates({ trueRate: 0, falseRate: 0, trueCount: 0, falseCount: 0, totalCount: 0 }); + return; + } + + const totalAnswers = students.length; + const trueAnswers = students.filter(student => + student.answers.some(ans => + ans.idQuestion === Number(question.id) && ans.answer.some(a => a === true) + ) + ).length; + const falseAnswers = students.filter(student => + student.answers.some(ans => + ans.idQuestion === Number(question.id) && ans.answer.some(a => a === false) + ) + ).length; + + setPickRates({ + trueRate: (trueAnswers / totalAnswers) * 100, + falseRate: (falseAnswers / totalAnswers) * 100, + trueCount: trueAnswers, + falseCount: falseAnswers, + totalCount: totalAnswers + }); + }; + return ( -
-
-
-
-
- + + + +
+ {question.formattedGlobalFeedback && showAnswer && ( +
+
)} - - )} - -
- {question.formattedGlobalFeedback && showAnswer && ( -
-
- )} - {!showAnswer && handleOnSubmitAnswer && ( - - )} +
); }; diff --git a/client/src/components/QuestionsDisplay/questionStyle.css b/client/src/components/QuestionsDisplay/questionStyle.css index f300ba2..2babc91 100644 --- a/client/src/components/QuestionsDisplay/questionStyle.css +++ b/client/src/components/QuestionsDisplay/questionStyle.css @@ -169,3 +169,35 @@ .choices-wrapper { width: 90%; } + +.progress-bar-container { + position: relative; + width: 100%; + height: 20px; + background-color: #FEFEFE; + border-radius: 8px; + overflow: hidden; + border: 1px solid black; +} + +.progress-bar-fill { + height: 100%; + background-color: royalblue; + width: 0%; + transition: width 0.6s ease; +} + +.progress-bar-text { + position: absolute; + width: 100%; + text-align: center; + top: 0; + line-height: 20px; + color: Black; +} + +.pick-rate{ + color: rgba(0,0,0,1); + min-width: 120px; +} + diff --git a/client/src/main.tsx b/client/src/main.tsx index e73c979..a04f569 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -6,6 +6,7 @@ import { BrowserRouter } from 'react-router-dom'; import { ThemeProvider, createTheme } from '@mui/material'; import '@fortawesome/fontawesome-free/css/all.min.css'; +import 'bootstrap/dist/css/bootstrap.min.css'; import './cssReset.css'; import './index.css'; diff --git a/client/src/pages/Teacher/ManageRoom/ManageRoom.tsx b/client/src/pages/Teacher/ManageRoom/ManageRoom.tsx index b5116a7..8fe1715 100644 --- a/client/src/pages/Teacher/ManageRoom/ManageRoom.tsx +++ b/client/src/pages/Teacher/ManageRoom/ManageRoom.tsx @@ -201,7 +201,12 @@ const ManageRoom: React.FC = () => { socket.on('user-disconnected', (userId: string) => { console.log(`Student left: id = ${userId}`); - setStudents((prevUsers) => prevUsers.filter((user) => user.id !== userId)); + //setStudents((prevUsers) => prevUsers.filter((user) => user.id !== userId)); + setStudents(prevStudents => + prevStudents.map(student => + student.id === userId ? { ...student, isActive: false } : student + ) + ); }); setSocket(socket); @@ -520,7 +525,6 @@ const ManageRoom: React.FC = () => { {quizQuestions?.length} )} - {quizMode === 'teacher' && (
{/* { {currentQuestion && ( )} diff --git a/package-lock.json b/package-lock.json index 144d2f8..fb5535d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,18 @@ "packages": { "": { "dependencies": { - "axios-mock-adapter": "^2.1.0" + "@popperjs/core": "^2.11.8", + "axios-mock-adapter": "^2.1.0", + "bootstrap": "^5.3.5" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" } }, "node_modules/asynckit": { @@ -37,6 +48,24 @@ "axios": ">= 0.17.0" } }, + "node_modules/bootstrap": { + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.5.tgz", + "integrity": "sha512-ct1CHKtiobRimyGzmsSldEtM03E8fcEX4Tb3dGXz1V8faRwM50+vfHwTzOxB3IlKO7m+9vTH3s/3C6T2EAPeTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", diff --git a/package.json b/package.json index a8332a4..ca7551a 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,7 @@ { "dependencies": { - "axios-mock-adapter": "^2.1.0" + "@popperjs/core": "^2.11.8", + "axios-mock-adapter": "^2.1.0", + "bootstrap": "^5.3.5" } }