Compare commits

..

No commits in common. "aa50af402e52b1c04c1822c93556fe9e1923056f" and "db21581535eb4798711ec85608f121ca6c211d7c" have entirely different histories.

148 changed files with 4127 additions and 11551 deletions

View file

@ -1,38 +0,0 @@
---
name: Bug report
about: Créez un rapport pour nous aider à améliorer / Create a report to help us improve
title: "[BUG] "
labels: bug
assignees: ''
---
**Décrivez le bug / Describe the bug**
Une description claire et concise du bug. / A clear and concise description of what the bug is.
**Pour reproduire / To Reproduce**
Étapes pour reproduire le comportement : / Steps to reproduce the behavior:
1. Aller à '...' / Go to '...'
2. Cliquer sur '...' / Click on '...'
3. Faites défiler jusqu'à '...' / Scroll down to '...'
4. Voir l'erreur / See error
**Comportement attendu / Expected behavior**
Une description claire et concise de ce que vous attendiez. / A clear and concise description of what you expected to happen.
**Captures d'écran / Screenshots**
Si applicable, ajoutez des captures d'écran pour aider à expliquer votre problème. / If applicable, add screenshots to help explain your problem.
**Ordinateur (veuillez compléter les informations suivantes) / Desktop (please complete the following information):**
- Système d'exploitation : [par exemple, Windows, macOS, Linux] / OS: [e.g. Windows, macOS, Linux]
- Navigateur : [par exemple, Chrome, Firefox, Safari] / Browser [e.g. Chrome, Firefox, Safari]
- Version : [par exemple, 22] / Version [e.g. 22]
**Smartphone (veuillez compléter les informations suivantes) / Smartphone (please complete the following information):**
- Appareil : [par exemple, iPhone6, Samsung Galaxy S10] / Device: [e.g. iPhone6, Samsung Galaxy S10]
- Système d'exploitation : [par exemple, iOS 14.4, Android 11] / OS: [e.g. iOS 14.4, Android 11]
- Navigateur : [par exemple, navigateur par défaut, Safari] / Browser [e.g. stock browser, Safari]
- Version : [par exemple, 22] / Version [e.g. 22]
**Contexte supplémentaire / Additional context**
Ajoutez tout autre contexte concernant le problème ici. / Add any other context about the problem here.

View file

@ -1,20 +0,0 @@
---
name: Feature request
about: Suggérez une idée pour ce projet / Suggest an idea for this project
title: "[FEATURE] "
labels: enhancement
assignees: ''
---
**Votre demande de fonctionnalité est-elle liée à un problème ? Veuillez décrire. / Is your feature request related to a problem? Please describe.**
Une description claire et concise du problème. Par exemple, je suis toujours frustré lorsque [...] / A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Décrivez la solution que vous souhaitez / Describe the solution you'd like**
Une description claire et concise de ce que vous voulez qu'il se passe. / A clear and concise description of what you want to happen.
**Décrivez les alternatives que vous avez envisagées / Describe alternatives you've considered**
Une description claire et concise de toute autre solution ou fonctionnalité que vous avez envisagée. / A clear and concise description of any alternative solutions or features you've considered.
**Contexte supplémentaire / Additional context**
Ajoutez tout autre contexte ou capture d'écran concernant la demande de fonctionnalité ici. / Add any other context or screenshots about the feature request here.

View file

@ -8,50 +8,29 @@ on:
branches: branches:
- main - main
env:
MONGO_URI: mongodb://localhost:27017
MONGO_DATABASE: evaluetonsavoir
jobs: jobs:
lint-and-tests: tests:
strategy:
matrix:
directory: [client, server]
fail-fast: false
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - name: Check Out Repo
uses: actions/checkout@v4
- uses: actions/setup-node@v4 - name: Set up Node.js
uses: actions/setup-node@v4
with: with:
node-version: '20' node-version: '20'
cache: 'npm'
cache-dependency-path: ${{ matrix.directory }}/package-lock.json
- name: Process ${{ matrix.directory }} - name: Install Dependencies, lint and Run Tests
working-directory: ${{ matrix.directory }}
timeout-minutes: 5
run: | run: |
echo "Installing dependencies..." echo "Installing dependencies..."
npm install npm ci
echo "Running ESLint..." echo "Running ESLint..."
npx eslint . npx eslint .
echo "Running tests..." echo "Running tests..."
echo "::group::Installing dependencies for ${{ matrix.directory }}"
npm ci
echo "::endgroup::"
echo "::group::Running ESLint"
npx eslint . || {
echo "ESLint failed with exit code $?"
exit 1
}
echo "::endgroup::"
echo "::group::Running Tests"
npm test npm test
echo "::endgroup::" working-directory: ${{ matrix.directory }}
strategy:
matrix:
directory: [client, server]

6
.gitignore vendored
View file

@ -41,7 +41,6 @@ build/Release
# Dependency directories # Dependency directories
node_modules/ node_modules/
jspm_packages/ jspm_packages/
mongo-backup/
# Snowpack dependency directory (https://snowpack.dev/) # Snowpack dependency directory (https://snowpack.dev/)
web_modules/ web_modules/
@ -123,13 +122,10 @@ dist
# Stores VSCode versions used for testing VSCode extensions # Stores VSCode versions used for testing VSCode extensions
.vscode-test .vscode-test
.env
launch.json
# yarn v2 # yarn v2
.yarn/cache .yarn/cache
.yarn/unplugged .yarn/unplugged
.yarn/build-state.yml .yarn/build-state.yml
.yarn/install-state.gz .yarn/install-state.gz
.pnp.* .pnp.*
db-backup/ db-backup/

View file

@ -1,33 +0,0 @@
{
"folders": [
{
"path": "."
},
{
"name": "server",
"path": "server"
},
{
"name": "client",
"path": "client"
}
],
"settings": {
"jest.disabledWorkspaceFolders": [
"EvalueTonSavoir"
]
},
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"eslint.validate": [
"javascript",
"typescript",
"javascriptreact",
"typescriptreact"
],
// use the same eslint config as `npx eslint`
"eslint.experimental.useFlatConfig": true,
"eslint.nodePath": "./node_modules"
}

View file

@ -3,7 +3,6 @@ MIT License
Copyright (c) 2023 ETS-PFE004-Plateforme-sondage-minitest Copyright (c) 2023 ETS-PFE004-Plateforme-sondage-minitest
Copyright (c) 2024 Louis-Antoine Caron, Mathieu Roy, Mélanie St-Hilaire, Samy Waddah Copyright (c) 2024 Louis-Antoine Caron, Mathieu Roy, Mélanie St-Hilaire, Samy Waddah
Copyright (c) 2024 Gabriel Moisan-Matte, Mathieu Sévigny-Lavallée, Jerry Kwok Hiu Fung, Bruno Roesner, Florent Serres Copyright (c) 2024 Gabriel Moisan-Matte, Mathieu Sévigny-Lavallée, Jerry Kwok Hiu Fung, Bruno Roesner, Florent Serres
Copyright (c) 2025 Nouhaïla Aâter, Kendrick Chan Hing Wah, Philippe Côté, Edwin Stanley Lopez Andino, Ana Lucia Munteanu
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View file

@ -1,30 +0,0 @@
[![CI/CD Pipeline for Frontend](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/frontend-deploy.yml/badge.svg)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/frontend-deploy.yml)
[![CI/CD Pipeline for Backend](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/backend-deploy.yml/badge.svg)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/backend-deploy.yml)
[![CI/CD Pipeline for Nginx Router](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/deploy.yml/badge.svg)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/deploy.yml)
[![en](https://img.shields.io/badge/lang-en-red.svg)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/blob/master/README.md)
# EvalueTonSavoir
EvalueTonSavoir est une plateforme open source et auto-hébergée qui poursuit le développement du code provenant de https://github.com/ETS-PFE004-Plateforme-sondage-minitest. Cette plateforme minimaliste est conçue comme un outil d'apprentissage et d'enseignement, offrant une solution simple et efficace pour la création de quiz utilisant le format GIFT, similaire à Moodle.
## Fonctionnalités clés
* Open Source et Auto-hébergé : Possédez et contrôlez vos données en déployant la plateforme sur votre propre infrastructure.
* Compatibilité GIFT : Créez des quiz facilement en utilisant le format GIFT, permettant une intégration transparente avec d'autres systèmes d'apprentissage.
* Minimaliste et Efficace : Une approche bare bones pour garantir la simplicité et la facilité d'utilisation, mettant l'accent sur l'essentiel de l'apprentissage.
## Contribution
Actuellement, il n'y a pas de modèle établi pour les contributions. Si vous constatez quelque chose de manquant ou si vous pensez qu'une amélioration est possible, n'hésitez pas à ouvrir un issue et/ou une PR)
## Liens utiles
* [Dépôt d'origine Frontend](https://github.com/ETS-PFE004-Plateforme-sondage-minitest/ETS-PFE004-EvalueTonSavoir-Frontend)
* [Dépôt d'origine Backend](https://github.com/ETS-PFE004-Plateforme-sondage-minitest/ETS-PFE004-EvalueTonSavoir-Backend)
* [Documentation (Wiki)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/wiki)
## License
EvalueTonSavoir is open-sourced and licensed under the [MIT License](/LICENSE).

View file

@ -2,26 +2,24 @@
[![CI/CD Pipeline for Backend](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/backend-deploy.yml/badge.svg)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/backend-deploy.yml) [![CI/CD Pipeline for Backend](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/backend-deploy.yml/badge.svg)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/backend-deploy.yml)
[![CI/CD Pipeline for Nginx Router](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/deploy.yml/badge.svg)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/deploy.yml) [![CI/CD Pipeline for Nginx Router](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/deploy.yml/badge.svg)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/actions/workflows/deploy.yml)
[![fr-ca](https://img.shields.io/badge/lang-fr--ca-green.svg)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/blob/main/README.fr-ca.md) # EvalueTonSavoir
# EvalueTonSavoir EvalueTonSavoir est une plateforme open source et auto-hébergée qui poursuit le développement du code provenant de https://github.com/ETS-PFE004-Plateforme-sondage-minitest. Cette plateforme minimaliste est conçue comme un outil d'apprentissage et d'enseignement, offrant une solution simple et efficace pour la création de quiz utilisant le format GIFT, similaire à Moodle.
EvalueTonSavoir is an open-source and self-hosted platform that continues the development of the code from https://github.com/ETS-PFE004-Plateforme-sondage-minitest. This minimalist platform is designed as a learning and teaching tool, offering a simple and effective solution for creating quizzes using the GIFT format, similar to Moodle. ## Fonctionnalités clés
## Key Features * Open Source et Auto-hébergé : Possédez et contrôlez vos données en déployant la plateforme sur votre propre infrastructure.
* Compatibilité GIFT : Créez des quiz facilement en utilisant le format GIFT, permettant une intégration transparente avec d'autres systèmes d'apprentissage.
* **Open Source and Self-Hosted**: Own and control your data by deploying the platform on your own infrastructure. * Minimaliste et Efficace : Une approche bare bones pour garantir la simplicité et la facilité d'utilisation, mettant l'accent sur l'essentiel de l'apprentissage.
* **GIFT Compatibility**: Easily create quizzes using the GIFT format, enabling seamless integration with other learning systems.
* **Minimalist and Efficient**: A bare-bones approach to ensure simplicity and ease of use, focusing on the essentials of learning.
## Contribution ## Contribution
Currently, there is no established model for contributions. If you notice something missing or think an improvement is possible, feel free to open an issue and/or a PR. Actuellement, il n'y a pas de modèle établi pour les contributions. Si vous constatez quelque chose de manquant ou si vous pensez qu'une amélioration est possible, n'hésitez pas à ouvrir un issue et/ou une PR)
## Useful Links ## Liens utiles
* [Original Frontend Repository](https://github.com/ETS-PFE004-Plateforme-sondage-minitest/ETS-PFE004-EvalueTonSavoir-Frontend) * [Dépôt d'origine Frontend](https://github.com/ETS-PFE004-Plateforme-sondage-minitest/ETS-PFE004-EvalueTonSavoir-Frontend)
* [Original Backend Repository](https://github.com/ETS-PFE004-Plateforme-sondage-minitest/ETS-PFE004-EvalueTonSavoir-Backend) * [Dépôt d'origine Backend](https://github.com/ETS-PFE004-Plateforme-sondage-minitest/ETS-PFE004-EvalueTonSavoir-Backend)
* [Documentation (Wiki)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/wiki) * [Documentation (Wiki)](https://github.com/ets-cfuhrman-pfe/EvalueTonSavoir/wiki)
## License ## License

View file

@ -1,2 +1,2 @@
VITE_BACKEND_URL=http://localhost:4400 VITE_BACKEND_URL=http://localhost:4400
VITE_AZURE_BACKEND_URL=http://localhost:4400 VITE_AZURE_BACKEND_URL=http://localhost:4400

19
client/.eslintrc.cjs Normal file
View file

@ -0,0 +1,19 @@
// eslint-disable-next-line no-undef
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

View file

@ -1,4 +1,4 @@
/* eslint-disable no-undef */
module.exports = { module.exports = {
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript'] presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript']
}; };

View file

@ -1,77 +1,29 @@
import react from "eslint-plugin-react";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import typescriptParser from "@typescript-eslint/parser";
import globals from "globals"; import globals from "globals";
import jest from "eslint-plugin-jest"; import pluginJs from "@eslint/js";
import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint";
import unusedImports from "eslint-plugin-unused-imports"; import pluginReact from "eslint-plugin-react";
import eslintComments from "eslint-plugin-eslint-comments";
/** @type {import('eslint').Linter.Config[]} */ /** @type {import('eslint').Linter.Config[]} */
export default [ export default [
{ {
ignores: ["node_modules", "dist/**/*"], files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"],
languageOptions: {
globals: globals.browser,
}, },
{ rules: {
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"], "no-unused-vars": ["error", {
languageOptions: { "argsIgnorePattern": "^_",
parser: typescriptParser, "varsIgnorePattern": "^_",
parserOptions: { "caughtErrorsIgnorePattern": "^_" // Ignore catch clause parameters that start with _
ecmaFeatures: { }],
jsx: true, },
}, settings: {
}, react: {
globals: { version: "detect", // Automatically detect the React version
...globals.serviceworker, },
...globals.browser, },
...globals.jest, },
...globals.node, pluginJs.configs.recommended,
process: "readonly", ...tseslint.configs.recommended,
}, pluginReact.configs.flat.recommended,
},
plugins: {
"@typescript-eslint": typescriptEslint,
react,
jest,
"react-refresh": reactRefresh,
"unused-imports": unusedImports,
"eslint-comments": eslintComments
},
rules: {
// Auto-fix unused variables
"@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off",
"unused-imports/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_",
"args": "after-used",
"argsIgnorePattern": "^_",
"destructuredArrayIgnorePattern": "^_"
}
],
// Handle directive comments
"eslint-comments/no-unused-disable": "warn",
"eslint-comments/no-unused-enable": "warn",
// Jest configurations
"jest/valid-expect": ["error", { "alwaysAwait": true }],
"jest/prefer-to-have-length": "warn",
"jest/no-disabled-tests": "off",
"jest/no-focused-tests": "error",
"jest/no-identical-title": "error",
// React refresh
"react-refresh/only-export-components": ["warn", {
allowConstantExport: true
}],
},
settings: {
react: {
version: "detect",
},
},
}
]; ];

View file

@ -1,4 +1,4 @@
/* eslint-disable no-undef */
/** @type {import('ts-jest').JestConfigWithTsJest} */ /** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = { module.exports = {

View file

@ -1,3 +1,3 @@
/* eslint-disable no-undef */
process.env.VITE_BACKEND_URL = 'http://localhost:4000/'; process.env.VITE_BACKEND_URL = 'http://localhost:4000/';
process.env.VITE_BACKEND_SOCKET_URL = 'https://ets-glitch-backend.glitch.me/'; process.env.VITE_BACKEND_SOCKET_URL = 'https://ets-glitch-backend.glitch.me/';

3327
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,77 +4,70 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "cross-env MODE=development VITE_BACKEND_URL=http://localhost:4400 vite --host", "dev": "vite --host",
"build": "tsc && vite build", "build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview", "preview": "vite preview",
"test": "jest --colors --silent", "test": "jest --colors",
"test:watch": "jest --watch" "test:watch": "jest --watch"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0", "@emotion/react": "^11.11.3",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.11.0",
"@fortawesome/fontawesome-free": "^6.7.2", "@fortawesome/fontawesome-free": "^6.4.2",
"@fortawesome/fontawesome-svg-core": "^6.7.2", "@fortawesome/fontawesome-svg-core": "^6.6.0",
"@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@mui/icons-material": "^7.0.1", "@mui/icons-material": "^6.1.0",
"@mui/lab": "^5.0.0-alpha.153", "@mui/lab": "^5.0.0-alpha.153",
"@mui/material": "^7.0.1", "@mui/material": "^6.1.0",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",
"axios": "^1.8.1", "axios": "^1.6.7",
"dompurify": "^3.2.5", "dompurify": "^3.2.3",
"esbuild": "^0.25.2", "esbuild": "^0.23.1",
"gift-pegjs": "^2.0.0-beta.1", "gift-pegjs": "^2.0.0-beta.1",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"jwt-decode": "^4.0.0",
"katex": "^0.16.11", "katex": "^0.16.11",
"marked": "^15.0.8", "marked": "^14.1.2",
"nanoid": "^5.1.5", "nanoid": "^5.0.2",
"qrcode.react": "^4.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-modal": "^3.16.3", "react-modal": "^3.16.1",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
"remark-math": "^6.0.0", "remark-math": "^6.0.0",
"socket.io-client": "^4.7.2", "socket.io-client": "^4.7.2",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"uuid": "^11.1.0", "uuid": "^9.0.1",
"vite-plugin-checker": "^0.9.1" "vite-plugin-checker": "^0.8.0"
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-env": "^7.26.9", "@babel/preset-env": "^7.23.3",
"@babel/preset-react": "^7.26.3", "@babel/preset-react": "^7.23.3",
"@babel/preset-typescript": "^7.27.0", "@babel/preset-typescript": "^7.23.3",
"@eslint/js": "^9.24.0", "@eslint/js": "^9.18.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.5.0",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/node": "^22.14.0", "@types/node": "^22.5.5",
"@types/react": "^18.2.15", "@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/react-latex": "^2.0.3", "@types/react-latex": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^8.29.1", "@typescript-eslint/eslint-plugin": "^8.5.0",
"@typescript-eslint/parser": "^8.29.1", "@typescript-eslint/parser": "^8.5.0",
"@vitejs/plugin-react-swc": "^3.8.1", "@vitejs/plugin-react-swc": "^3.7.2",
"cross-env": "^7.0.3", "eslint": "^9.18.0",
"eslint": "^9.24.0", "eslint-plugin-react": "^7.37.3",
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-jest": "^28.11.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.1.0-rc-206df66e-20240912", "eslint-plugin-react-hooks": "^5.1.0-rc-206df66e-20240912",
"eslint-plugin-react-refresh": "^0.4.19", "eslint-plugin-react-refresh": "^0.4.12",
"eslint-plugin-unused-imports": "^4.1.4",
"globals": "^15.14.0", "globals": "^15.14.0",
"identity-obj-proxy": "^3.0.0", "identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"ts-jest": "^29.3.1", "ts-jest": "^29.1.1",
"typescript": "^5.8.3", "typescript": "^5.6.2",
"typescript-eslint": "^8.29.1", "typescript-eslint": "^8.19.1",
"vite": "^6.2.0", "vite": "^5.4.5",
"vite-plugin-environment": "^1.1.3" "vite-plugin-environment": "^1.1.3"
} }
} }

View file

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import { useEffect, useState } from 'react'; // App.tsx
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route } from 'react-router-dom';
// Page main // Page main
import Home from './pages/Home/Home'; import Home from './pages/Home/Home';
@ -8,55 +8,37 @@ import Home from './pages/Home/Home';
// Pages espace enseignant // Pages espace enseignant
import Dashboard from './pages/Teacher/Dashboard/Dashboard'; import Dashboard from './pages/Teacher/Dashboard/Dashboard';
import Share from './pages/Teacher/Share/Share'; import Share from './pages/Teacher/Share/Share';
import Register from './pages/AuthManager/providers/SimpleLogin/Register'; import Login from './pages/Teacher/Login/Login';
import ResetPassword from './pages/AuthManager/providers/SimpleLogin/ResetPassword'; import Register from './pages/Teacher/Register/Register';
import ResetPassword from './pages/Teacher/ResetPassword/ResetPassword';
import ManageRoom from './pages/Teacher/ManageRoom/ManageRoom'; import ManageRoom from './pages/Teacher/ManageRoom/ManageRoom';
import QuizForm from './pages/Teacher/EditorQuiz/EditorQuiz'; import QuizForm from './pages/Teacher/EditorQuiz/EditorQuiz';
// Pages espace étudiant // Pages espace étudiant
import JoinRoom from './pages/Student/JoinRoom/JoinRoom'; import JoinRoom from './pages/Student/JoinRoom/JoinRoom';
// Pages authentification selection
import AuthDrawer from './pages/AuthManager/AuthDrawer';
// Header/Footer import // Header/Footer import
import Header from './components/Header/Header'; import Header from './components/Header/Header';
import Footer from './components/Footer/Footer'; import Footer from './components/Footer/Footer';
import ApiService from './services/ApiService'; import ApiService from './services/ApiService';
import OAuthCallback from './pages/AuthManager/callback/AuthCallback';
const App: React.FC = () => { const handleLogout = () => {
const [isAuthenticated, setIsAuthenticated] = useState(ApiService.isLoggedIn()); ApiService.logout();
const [isTeacherAuthenticated, setIsTeacherAuthenticated] = useState(ApiService.isLoggedInTeacher()); }
const [isRoomRequireAuthentication, setRoomsRequireAuth] = useState(null);
const location = useLocation();
// Check login status every time the route changes const isLoggedIn = () => {
useEffect(() => { return ApiService.isLoggedIn();
const checkLoginStatus = () => { }
setIsAuthenticated(ApiService.isLoggedIn());
setIsTeacherAuthenticated(ApiService.isLoggedInTeacher());
};
const fetchAuthenticatedRooms = async () => {
const data = await ApiService.getRoomsRequireAuth();
setRoomsRequireAuth(data);
};
checkLoginStatus();
fetchAuthenticatedRooms();
}, [location]);
const handleLogout = () => {
ApiService.logout();
setIsAuthenticated(false);
setIsTeacherAuthenticated(false);
};
function App() {
return ( return (
<div className="content"> <div className="content">
<Header isLoggedIn={isAuthenticated} handleLogout={handleLogout} />
<Header
isLoggedIn={isLoggedIn}
handleLogout={handleLogout}/>
<div className="app"> <div className="app">
<main> <main>
<Routes> <Routes>
@ -64,46 +46,22 @@ const App: React.FC = () => {
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
{/* Pages espace enseignant */} {/* Pages espace enseignant */}
<Route <Route path="/teacher/login" element={<Login />} />
path="/teacher/dashboard" <Route path="/teacher/register" element={<Register />} />
element={isTeacherAuthenticated ? <Dashboard /> : <Navigate to="/login" />} <Route path="/teacher/resetPassword" element={<ResetPassword />} />
/> <Route path="/teacher/dashboard" element={<Dashboard />} />
<Route <Route path="/teacher/share/:id" element={<Share />} />
path="/teacher/share/:id" <Route path="/teacher/editor-quiz/:id" element={<QuizForm />} />
element={isTeacherAuthenticated ? <Share /> : <Navigate to="/login" />} <Route path="/teacher/manage-room/:id" element={<ManageRoom />} />
/>
<Route
path="/teacher/editor-quiz/:id"
element={isTeacherAuthenticated ? <QuizForm /> : <Navigate to="/login" />}
/>
<Route
path="/teacher/manage-room/:quizId/:roomName"
element={isTeacherAuthenticated ? <ManageRoom /> : <Navigate to="/login" />}
/>
{/* Pages espace étudiant */} {/* Pages espace étudiant */}
<Route <Route path="/student/join-room" element={<JoinRoom />} />
path="/student/join-room"
element={( !isRoomRequireAuthentication || isAuthenticated ) ? <JoinRoom /> : <Navigate to="/login" />}
/>
{/* Pages authentification */}
<Route path="/login" element={<AuthDrawer />} />
{/* Pages enregistrement */}
<Route path="/register" element={<Register />} />
{/* Pages rest password */}
<Route path="/resetPassword" element={<ResetPassword />} />
{/* Pages authentification sélection */}
<Route path="/auth/callback" element={<OAuthCallback />} />
</Routes> </Routes>
</main> </main>
</div> </div>
<Footer /> <Footer/>
</div> </div>
); );
}; }
export default App; export default App;

View file

@ -1,17 +0,0 @@
export interface ImageType {
id: string;
file_content: string;
file_name: string;
mime_type: string;
}
export interface ImagesResponse {
images: ImageType[];
total: number;
}
export interface ImagesParams {
page: number;
limit: number;
uid?: string;
}

View file

@ -1,17 +0,0 @@
export interface Images {
id: string;
file_content: string;
file_name: string;
mime_type: string;
}
export interface ImagesResponse {
images: Images[];
total: number;
}
export interface ImagesParams {
page: number;
limit: number;
uid?: string;
}

View file

@ -1,6 +0,0 @@
export interface RoomType {
_id: string;
userId: string;
title: string;
created_at: string;
}

View file

@ -1,7 +1,5 @@
import { AnswerType } from "src/pages/Student/JoinRoom/JoinRoom";
export interface Answer { export interface Answer {
answer: AnswerType; answer: string | number | boolean;
isCorrect: boolean; isCorrect: boolean;
idQuestion: number; idQuestion: number;
} }

View file

@ -1,17 +0,0 @@
import { RoomType } from "../../Types/RoomType";
const room: RoomType = {
_id: '123',
userId: '456',
title: 'Test Room',
created_at: '2025-02-21T00:00:00Z'
};
describe('RoomType', () => {
test('creates a room with _id, userId, title, and created_at', () => {
expect(room._id).toBe('123');
expect(room.userId).toBe('456');
expect(room.title).toBe('Test Room');
expect(room.created_at).toBe('2025-02-21T00:00:00Z');
});
});

View file

@ -12,6 +12,6 @@ describe('StudentType', () => {
expect(user.name).toBe('Student'); expect(user.name).toBe('Student');
expect(user.id).toBe('123'); expect(user.id).toBe('123');
expect(user.answers).toHaveLength(0); expect(user.answers.length).toBe(0);
}); });
}); });

View file

@ -3,87 +3,49 @@ import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import GIFTTemplatePreview from 'src/components/GiftTemplate/GIFTTemplatePreview'; import GIFTTemplatePreview from 'src/components/GiftTemplate/GIFTTemplatePreview';
const validQuestions = [
'::TFTitle::[markdown]Troo statement {TRUE}',
'::SATitle::[markdown]What is the answer? {=ShortAnswerOne =ShortAnswerTwo}',
'::MCQTitle::[markdown]MultiChoice question? {=MQAnswerOne ~MQAnswerTwo#feedback####Gen feedback}',
];
const unsupportedQuestions = [
'::Title::[markdown]Essay {}',
'::Title::[markdown]Matching {}',
'::Title::[markdown]Description',
'$CATEGORY a/b/c'
];
describe('GIFTTemplatePreview Component', () => { describe('GIFTTemplatePreview Component', () => {
it('renders error message when questions contain invalid syntax', () => { test('renders error message when questions contain invalid syntax', () => {
render(<GIFTTemplatePreview questions={['T{']} hideAnswers={false} />); render(<GIFTTemplatePreview questions={['Invalid GIFT syntax']} />);
const previewContainer = screen.getByTestId('preview-container'); const errorMessage = screen.findByText(/Erreur inconnue/i, {}, { timeout: 5000 });
expect(previewContainer).toBeInTheDocument(); expect(errorMessage).resolves.toBeInTheDocument();
const errorMessage = previewContainer.querySelector('div[label="error-message"]');
expect(errorMessage).toBeInTheDocument();
}); });
test('renders preview when valid questions are provided', () => {
it('renders preview when valid questions are provided, including answers, has no errors', () => { const questions = [
render(<GIFTTemplatePreview questions={validQuestions} hideAnswers={false} />); 'Question 1 { A | B | C }',
'Question 2 { D | E | F }',
];
render(<GIFTTemplatePreview questions={questions} />);
const previewContainer = screen.getByTestId('preview-container'); const previewContainer = screen.getByTestId('preview-container');
expect(previewContainer).toBeInTheDocument(); expect(previewContainer).toBeInTheDocument();
// Check that all question titles are rendered inside the previewContainer
validQuestions.forEach((question) => {
const title = question.split('::')[1].split('::')[0];
expect(previewContainer).toHaveTextContent(title);
});
// There should be no errors
const errorMessage = previewContainer.querySelector('div[label="error-message"]');
expect(errorMessage).not.toBeInTheDocument();
// Check that some stems and answers are rendered inside the previewContainer
expect(previewContainer).toHaveTextContent('Troo statement');
expect(previewContainer).toHaveTextContent('What is the answer?');
expect(previewContainer).toHaveTextContent('MultiChoice question?');
expect(previewContainer).toHaveTextContent('Vrai');
// short answers are stored in a textbox
const answerInputElements = screen.getAllByRole('textbox');
const giftInputElements = answerInputElements.filter(element => element.classList.contains('gift-input'));
expect(giftInputElements).toHaveLength(1);
expect(giftInputElements[0]).toHaveAttribute('placeholder', 'ShortAnswerOne, ShortAnswerTwo');
// Check for correct answer icon just after MQAnswerOne
const mqAnswerOneElement = screen.getByText('MQAnswerOne');
const correctAnswerIcon = mqAnswerOneElement.parentElement?.querySelector('[data-testid="correct-icon"]');
expect(correctAnswerIcon).toBeInTheDocument();
// Check for incorrect answer icon just after MQAnswerTwo
const mqAnswerTwoElement = screen.getByText('MQAnswerTwo');
const incorrectAnswerIcon = mqAnswerTwoElement.parentElement?.querySelector('[data-testid="incorrect-icon"]');
expect(incorrectAnswerIcon).toBeInTheDocument();
});
it('hides answers when hideAnswers prop is true', () => {
render(<GIFTTemplatePreview questions={validQuestions} hideAnswers={true} />);
const previewContainer = screen.getByTestId('preview-container');
expect(previewContainer).toBeInTheDocument();
expect(previewContainer).toHaveTextContent('Troo statement');
expect(previewContainer).toHaveTextContent('What is the answer?');
expect(previewContainer).toHaveTextContent('MultiChoice question?');
expect(previewContainer).toHaveTextContent('Vrai');
expect(previewContainer).not.toHaveTextContent('ShortAnswerOne');
expect(previewContainer).not.toHaveTextContent('ShortAnswerTwo');
// shouldn't have correct/incorrect icons
const correctAnswerIcon = screen.queryByTestId('correct-icon');
expect(correctAnswerIcon).not.toBeInTheDocument();
const incorrectAnswerIcon = screen.queryByTestId('incorrect-icon');
expect(incorrectAnswerIcon).not.toBeInTheDocument();
}); });
test('hides answers when hideAnswers prop is true', () => {
it('should indicate in the preview that unsupported GIFT questions are not supported', () => { const questions = [
render(<GIFTTemplatePreview questions={unsupportedQuestions} hideAnswers={false} />); 'Question 1 { A | B | C }',
'Question 2 { D | E | F }',
];
render(<GIFTTemplatePreview questions={questions} hideAnswers />);
const previewContainer = screen.getByTestId('preview-container'); const previewContainer = screen.getByTestId('preview-container');
expect(previewContainer).toBeInTheDocument(); expect(previewContainer).toBeInTheDocument();
// find all unsupported errors (should be 4)
const unsupportedMessages = previewContainer.querySelectorAll('div[label="error-message"]');
expect(unsupportedMessages).toHaveLength(4);
}); });
// it('renders images correctly', () => {
// const questions = [
// 'Question 1',
// '<img src="image1.jpg" alt="Image 1">',
// 'Question 2',
// '<img src="image2.jpg" alt="Image 2">',
// ];
// const { getByAltText } = render(<GIFTTemplatePreview questions={questions} />);
// const image1 = getByAltText('Image 1');
// const image2 = getByAltText('Image 2');
// expect(image1).toBeInTheDocument();
// expect(image2).toBeInTheDocument();
// });
// it('renders non-images correctly', () => {
// const questions = ['Question 1', 'Question 2'];
// const { queryByAltText } = render(<GIFTTemplatePreview questions={questions} />);
// const image1 = queryByAltText('Image 1');
// const image2 = queryByAltText('Image 2');
// expect(image1).toBeNull();
// expect(image2).toBeNull();
// });
}); });

View file

@ -1,95 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import LiveResults from 'src/components/LiveResults/LiveResults';
import { QuestionType } from 'src/Types/QuestionType';
import { StudentType } from 'src/Types/StudentType';
import { BaseQuestion, parse } from 'gift-pegjs';
const mockGiftQuestions = parse(
`::Sample Question 1:: Sample Question 1 {=Answer 1 ~Answer 2}
::Sample Question 2:: Sample Question 2 {T}`);
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
if (question.type !== "Category")
question.id = (index + 1).toString();
const newMockQuestion = question;
return {question : newMockQuestion as BaseQuestion};
});
const mockStudents: StudentType[] = [
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: ['Answer 1'], isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: ['Answer 2'], isCorrect: false }] },
];
const mockShowSelectedQuestion = jest.fn();
describe('LiveResults', () => {
test('renders LiveResults component', () => {
render(
<LiveResults
socket={null}
questions={mockQuestions}
showSelectedQuestion={mockShowSelectedQuestion}
quizMode="teacher"
students={mockStudents}
/>
);
expect(screen.getByText('Résultats du quiz')).toBeInTheDocument();
});
test('toggles show usernames switch', () => {
render(
<LiveResults
socket={null}
questions={mockQuestions}
showSelectedQuestion={mockShowSelectedQuestion}
quizMode="teacher"
students={mockStudents}
/>
);
const switchElement = screen.getByLabelText('Afficher les noms');
expect(switchElement).toBeInTheDocument();
fireEvent.click(switchElement);
expect(switchElement).toBeChecked();
});
test('toggles show correct answers switch', () => {
render(
<LiveResults
socket={null}
questions={mockQuestions}
showSelectedQuestion={mockShowSelectedQuestion}
quizMode="teacher"
students={mockStudents}
/>
);
const switchElement = screen.getByLabelText('Afficher les réponses');
expect(switchElement).toBeInTheDocument();
fireEvent.click(switchElement);
expect(switchElement).toBeChecked();
});
test('calls showSelectedQuestion when a table cell is clicked', () => {
render(
<LiveResults
socket={null}
questions={mockQuestions}
showSelectedQuestion={mockShowSelectedQuestion}
quizMode="teacher"
students={mockStudents}
/>
);
const tableCell = screen.getByText('Q1');
fireEvent.click(tableCell);
expect(mockShowSelectedQuestion).toHaveBeenCalled();
});
});

View file

@ -1,110 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { StudentType } from 'src/Types/StudentType';
import LiveResultsTable from 'src/components/LiveResults/LiveResultsTable/LiveResultsTable';
import { QuestionType } from 'src/Types/QuestionType';
import { BaseQuestion, parse } from 'gift-pegjs';
const mockGiftQuestions = parse(
`::Sample Question 1:: Sample Question 1 {=Answer 1 ~Answer 2}
::Sample Question 2:: Sample Question 2 {T}`);
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
if (question.type !== "Category")
question.id = (index + 1).toString();
const newMockQuestion = question;
return {question : newMockQuestion as BaseQuestion};
});
const mockStudents: StudentType[] = [
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: ['Answer 1'], isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: ['Answer 2'], isCorrect: false }] },
];
const mockShowSelectedQuestion = jest.fn();
describe('LiveResultsTable', () => {
test('renders LiveResultsTable component', () => {
render(
<LiveResultsTable
questions={mockQuestions}
students={mockStudents}
showCorrectAnswers={false}
showSelectedQuestion={mockShowSelectedQuestion}
showUsernames={true}
/>
);
expect(screen.getByText('Student 1')).toBeInTheDocument();
expect(screen.getByText('Student 2')).toBeInTheDocument();
});
test('displays correct and incorrect answers', () => {
render(
<LiveResultsTable
questions={mockQuestions}
students={mockStudents}
showCorrectAnswers={true}
showSelectedQuestion={mockShowSelectedQuestion}
showUsernames={true}
/>
);
expect(screen.getByText('Answer 1')).toBeInTheDocument();
expect(screen.getByText('Answer 2')).toBeInTheDocument();
});
test('calls showSelectedQuestion when a table cell is clicked', () => {
render(
<LiveResultsTable
questions={mockQuestions}
students={mockStudents}
showCorrectAnswers={true}
showSelectedQuestion={mockShowSelectedQuestion}
showUsernames={true}
/>
);
const tableCell = screen.getByText('Q1');
fireEvent.click(tableCell);
expect(mockShowSelectedQuestion).toHaveBeenCalled();
});
test('calculates and displays student grades', () => {
render(
<LiveResultsTable
questions={mockQuestions}
students={mockStudents}
showCorrectAnswers={true}
showSelectedQuestion={mockShowSelectedQuestion}
showUsernames={true}
/>
);
//50% because only one of the two questions have been answered (getALLByText, because there are a value 50% for the %reussite de la question
// and a second one for the student grade)
const gradeElements = screen.getAllByText('50 %');
expect(gradeElements).toHaveLength(2);
const gradeElements2 = screen.getAllByText('0 %');
expect(gradeElements2).toHaveLength(2); });
test('calculates and displays class average', () => {
render(
<LiveResultsTable
questions={mockQuestions}
students={mockStudents}
showCorrectAnswers={true}
showSelectedQuestion={mockShowSelectedQuestion}
showUsernames={true}
/>
);
//1 good answer out of 4 possible good answers (the second question has not been answered)
expect(screen.getByText('25 %')).toBeInTheDocument();
});
});

View file

@ -1,95 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { StudentType } from 'src/Types/StudentType';
import LiveResultsTableBody from 'src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableBody';
import { QuestionType } from 'src/Types/QuestionType';
import { BaseQuestion, parse } from 'gift-pegjs';
const mockGiftQuestions = parse(
`::Sample Question 1:: Sample Question 1 {=Answer 1 ~Answer 2}
::Sample Question 2:: Sample Question 2 {T}`);
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
if (question.type !== "Category")
question.id = (index + 1).toString();
const newMockQuestion = question;
return {question : newMockQuestion as BaseQuestion};
});
const mockStudents: StudentType[] = [
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: ['Answer 1'], isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: ['Answer 2'], isCorrect: false }] },
];
const mockGetStudentGrade = jest.fn((student: StudentType) => {
const correctAnswers = student.answers.filter(answer => answer.isCorrect).length;
return (correctAnswers / mockQuestions.length) * 100;
});
describe('LiveResultsTableBody', () => {
test('renders LiveResultsTableBody component', () => {
render(
<LiveResultsTableBody
maxQuestions={2}
students={mockStudents}
showUsernames={true}
showCorrectAnswers={false}
getStudentGrade={mockGetStudentGrade}
/>
);
expect(screen.getByText('Student 1')).toBeInTheDocument();
expect(screen.getByText('Student 2')).toBeInTheDocument();
});
test('displays correct and incorrect answers', () => {
render(
<LiveResultsTableBody
maxQuestions={2}
students={mockStudents}
showUsernames={true}
showCorrectAnswers={true}
getStudentGrade={mockGetStudentGrade}
/>
);
expect(screen.getByText('Answer 1')).toBeInTheDocument();
expect(screen.getByText('Answer 2')).toBeInTheDocument();
});
test('displays icons for correct and incorrect answers when showCorrectAnswers is false', () => {
render(
<LiveResultsTableBody
maxQuestions={2}
students={mockStudents}
showUsernames={true}
showCorrectAnswers={false}
getStudentGrade={mockGetStudentGrade}
/>
);
expect(screen.getByLabelText('correct')).toBeInTheDocument();
expect(screen.getByLabelText('incorrect')).toBeInTheDocument();
});
test('hides usernames when showUsernames is false', () => {
render(
<LiveResultsTableBody
maxQuestions={2}
students={mockStudents}
showUsernames={false}
showCorrectAnswers={true}
getStudentGrade={mockGetStudentGrade}
/>
);
expect(screen.getAllByText('******')).toHaveLength(2);
});
});

View file

@ -1,55 +0,0 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { StudentType } from 'src/Types/StudentType';
import LiveResultsTableFooter from 'src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultTableFooter';
const mockStudents: StudentType[] = [
{ id: "1", name: 'Student 1', answers: [{ idQuestion: 1, answer: ['Answer 1'], isCorrect: true }] },
{ id: "2", name: 'Student 2', answers: [{ idQuestion: 2, answer: ['Answer 2'], isCorrect: false }] },
];
const mockGetStudentGrade = jest.fn((student: StudentType) => {
const correctAnswers = student.answers.filter(answer => answer.isCorrect).length;
return (correctAnswers / 2) * 100; // Assuming there are 2 questions
});
describe('LiveResultsTableFooter', () => {
test('renders LiveResultsTableFooter component', () => {
render(
<LiveResultsTableFooter
maxQuestions={2}
students={mockStudents}
getStudentGrade={mockGetStudentGrade}
/>
);
expect(screen.getByText('% réussite')).toBeInTheDocument();
});
test('calculates and displays correct answers per question', () => {
render(
<LiveResultsTableFooter
maxQuestions={2}
students={mockStudents}
getStudentGrade={mockGetStudentGrade}
/>
);
expect(screen.getByText('50 %')).toBeInTheDocument();
expect(screen.getByText('0 %')).toBeInTheDocument();
});
test('calculates and displays class average', () => {
render(
<LiveResultsTableFooter
maxQuestions={2}
students={mockStudents}
getStudentGrade={mockGetStudentGrade}
/>
);
expect(screen.getByText('50 %')).toBeInTheDocument();
});
});

View file

@ -1,51 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import LiveResultsTableHeader from 'src/components/LiveResults/LiveResultsTable/TableComponents/LiveResultsTableHeader';
const mockShowSelectedQuestion = jest.fn();
describe('LiveResultsTableHeader', () => {
test('renders LiveResultsTableHeader component', () => {
render(
<LiveResultsTableHeader
maxQuestions={5}
showSelectedQuestion={mockShowSelectedQuestion}
/>
);
expect(screen.getByText("Nom d'utilisateur")).toBeInTheDocument();
for (let i = 1; i <= 5; i++) {
expect(screen.getByText(`Q${i}`)).toBeInTheDocument();
}
expect(screen.getByText('% réussite')).toBeInTheDocument();
});
test('calls showSelectedQuestion when a question header is clicked', () => {
render(
<LiveResultsTableHeader
maxQuestions={5}
showSelectedQuestion={mockShowSelectedQuestion}
/>
);
const questionHeader = screen.getByText('Q1');
fireEvent.click(questionHeader);
expect(mockShowSelectedQuestion).toHaveBeenCalledWith(0);
});
test('renders the correct number of question headers', () => {
render(
<LiveResultsTableHeader
maxQuestions={3}
showSelectedQuestion={mockShowSelectedQuestion}
/>
);
for (let i = 1; i <= 3; i++) {
expect(screen.getByText(`Q${i}`)).toBeInTheDocument();
}
});
});

View file

@ -54,10 +54,10 @@ describe('TextType', () => {
format: '' format: ''
}; };
// eslint-disable-next-line no-irregular-whitespace
// warning: there are zero-width spaces "" in the expected output -- you must enable seeing them with an extension such as Gremlins tracker in VSCode // warning: there are zero-width spaces "" in the expected output -- you must enable seeing them with an extension such as Gremlins tracker in VSCode
// eslint-disable-next-line no-irregular-whitespace
const expectedOutput = `Inline matrix: <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mo fence="true">(</mo><mtable rowspacing="0.16em"><mtr><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>a</mi></mstyle></mtd><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>b</mi></mstyle></mtd></mtr><mtr><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>c</mi></mstyle></mtd><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>d</mi></mstyle></mtd></mtr></mtable><mo fence="true">)</mo></mrow> \\begin{pmatrix} a &amp; b \\\\ c &amp; d \\end{pmatrix} </math></span><span aria-hidden="true" class="katex-html"><span class="base"><span style="height:2.4em;vertical-align:-0.95em;" class="strut"></span><span class="minner"><span style="top:0em;" class="mopen delimcenter"><span class="delimsizing size3">(</span></span><span class="mord"><span class="mtable"><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span style="height:1.45em;" class="vlist"><span style="top:-3.61em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">a</span></span></span><span style="top:-2.41em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">c</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span style="height:0.95em;" class="vlist"><span></span></span></span></span></span><span style="width:0.5em;" class="arraycolsep"></span><span style="width:0.5em;" class="arraycolsep"></span><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span style="height:1.45em;" class="vlist"><span style="top:-3.61em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">b</span></span></span><span style="top:-2.41em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">d</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span style="height:0.95em;" class="vlist"><span></span></span></span></span></span></span></span><span style="top:0em;" class="mclose delimcenter"><span class="delimsizing size3">)</span></span></span></span></span></span>`; const expectedOutput = `Inline matrix: <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><mrow><mo fence="true">(</mo><mtable rowspacing="0.16em"><mtr><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>a</mi></mstyle></mtd><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>b</mi></mstyle></mtd></mtr><mtr><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>c</mi></mstyle></mtd><mtd><mstyle displaystyle="false" scriptlevel="0"><mi>d</mi></mstyle></mtd></mtr></mtable><mo fence="true">)</mo></mrow> \\begin{pmatrix} a &amp; b \\\\ c &amp; d \\end{pmatrix} </math></span><span aria-hidden="true" class="katex-html"><span class="base"><span style="height:2.4em;vertical-align:-0.95em;" class="strut"></span><span class="minner"><span style="top:0em;" class="mopen delimcenter"><span class="delimsizing size3">(</span></span><span class="mord"><span class="mtable"><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span style="height:1.45em;" class="vlist"><span style="top:-3.61em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">a</span></span></span><span style="top:-2.41em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">c</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span style="height:0.95em;" class="vlist"><span></span></span></span></span></span><span style="width:0.5em;" class="arraycolsep"></span><span style="width:0.5em;" class="arraycolsep"></span><span class="col-align-c"><span class="vlist-t vlist-t2"><span class="vlist-r"><span style="height:1.45em;" class="vlist"><span style="top:-3.61em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">b</span></span></span><span style="top:-2.41em;"><span style="height:3em;" class="pstrut"></span><span class="mord"><span class="mord mathnormal">d</span></span></span></span><span class="vlist-s"></span></span><span class="vlist-r"><span style="height:0.95em;" class="vlist"><span></span></span></span></span></span></span></span><span style="top:0em;" class="mclose delimcenter"><span class="delimsizing size3">)</span></span></span></span></span></span>`;
expect(FormattedTextTemplate(input)).toContain(expectedOutput); expect(FormattedTextTemplate(input)).toContain(expectedOutput);
}); });

View file

@ -28,7 +28,7 @@ function convertStylesToObject(styles: string): React.CSSProperties {
styles.split(';').forEach((style) => { styles.split(';').forEach((style) => {
const [property, value] = style.split(':'); const [property, value] = style.split(':');
if (property && value) { if (property && value) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(styleObject as any)[property.trim()] = value.trim(); (styleObject as any)[property.trim()] = value.trim();
} }
}); });

View file

@ -29,7 +29,7 @@ const katekMock: TemplateOptions & MultipleChoiceQuestion = {
formattedStem: { format: 'plain' , text: '$$\\frac{zzz}{yyy}$$'}, formattedStem: { format: 'plain' , text: '$$\\frac{zzz}{yyy}$$'},
choices: [ choices: [
{ formattedText: { format: 'plain' , text: 'Choice 1'}, isCorrect: true, formattedFeedback: { format: 'plain' , text: 'Correct!'}, weight: 1 }, { formattedText: { format: 'plain' , text: 'Choice 1'}, isCorrect: true, formattedFeedback: { format: 'plain' , text: 'Correct!'}, weight: 1 },
{ formattedText: { format: 'plain', text: 'Choice 2' }, isCorrect: false, formattedFeedback: { format: 'plain' , text: 'Correct!'}, weight: 0 } { formattedText: { format: 'plain', text: 'Choice 2' }, isCorrect: true, formattedFeedback: { format: 'plain' , text: 'Correct!'}, weight: 1 }
], ],
formattedGlobalFeedback: { format: 'plain', text: 'Sample Global Feedback' } formattedGlobalFeedback: { format: 'plain', text: 'Sample Global Feedback' }
}; };

View file

@ -64,7 +64,7 @@ exports[`MultipleChoice snapshot test 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Choice 1 Choice 1
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="correct-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -88,7 +88,7 @@ exports[`MultipleChoice snapshot test 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Choice 2 Choice 2
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -180,7 +180,7 @@ exports[`MultipleChoice snapshot test with 2 images using markdown text format 1
Choice 1 Choice 1
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="correct-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -205,7 +205,7 @@ exports[`MultipleChoice snapshot test with 2 images using markdown text format 1
Choice 2 Choice 2
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -229,7 +229,7 @@ exports[`MultipleChoice snapshot test with 2 images using markdown text format 1
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
&lt;img alt="Sample Image" src="https://via.placeholder.com/150"&gt; &lt;img alt="Sample Image" src="https://via.placeholder.com/150"&gt;
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -321,7 +321,7 @@ exports[`MultipleChoice snapshot test with Moodle text format 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Choice 1 Choice 1
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="correct-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -345,7 +345,7 @@ exports[`MultipleChoice snapshot test with Moodle text format 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Choice 2 Choice 2
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -436,7 +436,7 @@ exports[`MultipleChoice snapshot test with image 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Choice 1 Choice 1
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="correct-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -460,7 +460,7 @@ exports[`MultipleChoice snapshot test with image 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Choice 2 Choice 2
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -484,7 +484,7 @@ exports[`MultipleChoice snapshot test with image 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
&lt;img alt="Sample Image" src="https://via.placeholder.com/150"&gt; &lt;img alt="Sample Image" src="https://via.placeholder.com/150"&gt;
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -577,7 +577,7 @@ exports[`MultipleChoice snapshot test with image using markdown text format 1`]
Choice 1 Choice 1
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="correct-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -602,7 +602,7 @@ exports[`MultipleChoice snapshot test with image using markdown text format 1`]
Choice 2 Choice 2
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -626,7 +626,7 @@ exports[`MultipleChoice snapshot test with image using markdown text format 1`]
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
&lt;img alt="Sample Image" src="https://via.placeholder.com/150"&gt; &lt;img alt="Sample Image" src="https://via.placeholder.com/150"&gt;
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -718,7 +718,7 @@ exports[`MultipleChoice snapshot test with katex 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Choice 1 Choice 1
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="correct-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -733,7 +733,7 @@ exports[`MultipleChoice snapshot test with katex 1`] = `
&lt;div class='multiple-choice-answers-container'&gt; &lt;div class='multiple-choice-answers-container'&gt;
&lt;input class="gift-input" type="radio" id="idmocked-id" name="idmocked-id"&gt; &lt;input class="gift-input" type="radio" id="idmocked-id" name="idmocked-id"&gt;
&lt;span class="answer-weight-container answer-positive-weight"&gt;1%&lt;/span&gt;
&lt;label style=" &lt;label style="
display: inline-block; display: inline-block;
padding: 0.2em 0 0.2em 0; padding: 0.2em 0 0.2em 0;
@ -742,15 +742,15 @@ exports[`MultipleChoice snapshot test with katex 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Choice 2 Choice 2
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
margin-right: 0.2rem; margin-right: 0.2rem;
width: 0.75em; width: 1em;
color: hsl(2, 64%, 58%); color: hsl(120, 39%, 54%);
" role="img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"&gt;&lt;path fill="currentColor" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"&gt;&lt;/path&gt;&lt;/svg&gt; " role="img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"&gt;&lt;path fill="currentColor" d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"&gt;&lt;/path&gt;&lt;/svg&gt;
&lt;span class="feedback-container"&gt;Correct!&lt;/span&gt; &lt;span class="feedback-container"&gt;Correct!&lt;/span&gt;
&lt;/input&gt; &lt;/input&gt;
&lt;/div&gt; &lt;/div&gt;
@ -833,7 +833,7 @@ exports[`MultipleChoice snapshot test with katex, using html text format 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Choice 1 Choice 1
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="correct-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -857,7 +857,7 @@ exports[`MultipleChoice snapshot test with katex, using html text format 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Choice 2 Choice 2
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;

View file

@ -150,7 +150,7 @@ exports[`TrueFalse snapshot test with katex 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Vrai Vrai
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="correct-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -174,7 +174,7 @@ exports[`TrueFalse snapshot test with katex 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Faux Faux
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -265,7 +265,7 @@ exports[`TrueFalse snapshot test with moodle 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Vrai Vrai
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="correct-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -289,7 +289,7 @@ exports[`TrueFalse snapshot test with moodle 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Faux Faux
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -380,7 +380,7 @@ exports[`TrueFalse snapshot test with plain text 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Vrai Vrai
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="correct-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;
@ -404,7 +404,7 @@ exports[`TrueFalse snapshot test with plain text 1`] = `
" for="idmocked-id"&gt; " for="idmocked-id"&gt;
Faux Faux
&lt;/label&gt; &lt;/label&gt;
&lt;svg data-testid="incorrect-icon" style=" &lt;svg style="
vertical-align: text-bottom; vertical-align: text-bottom;
display: inline-block; display: inline-block;
margin-left: 0.1rem; margin-left: 0.1rem;

View file

@ -1,147 +0,0 @@
import React, { act } from "react";
import "@testing-library/jest-dom";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import ImageGallery from "../../../components/ImageGallery/ImageGallery";
import ApiService from "../../../services/ApiService";
import { Images } from "../../../Types/Images";
import userEvent from "@testing-library/user-event";
jest.mock("../../../services/ApiService");
const mockImages: Images[] = [
{ id: "1", file_name: "image1.jpg", mime_type: "image/jpeg", file_content: "mockBase64Content1" },
{ id: "2", file_name: "image2.jpg", mime_type: "image/jpeg", file_content: "mockBase64Content2" },
{ id: "3", file_name: "image3.jpg", mime_type: "image/jpeg", file_content: "mockBase64Content3" },
];
beforeAll(() => {
global.URL.createObjectURL = jest.fn(() => 'mockedObjectUrl');
Object.assign(navigator, {
clipboard: {
writeText: jest.fn(),
},
});
});
describe("ImageGallery", () => {
let mockHandleDelete: jest.Mock;
beforeEach(async () => {
(ApiService.getUserImages as jest.Mock).mockResolvedValue({ images: mockImages, total: 3 });
(ApiService.deleteImage as jest.Mock).mockResolvedValue(true);
(ApiService.uploadImage as jest.Mock).mockResolvedValue('mockImageUrl');
await act(async () => {
render(<ImageGallery />);
});
mockHandleDelete = jest.fn();
});
it("should render images correctly", async () => {
await act(async () => {
await screen.findByText("Galerie");
});
expect(screen.getByAltText("Image image1.jpg")).toBeInTheDocument();
expect(screen.getByAltText("Image image2.jpg")).toBeInTheDocument();
});
it("should handle copy action", async () => {
const handleCopyMock = jest.fn();
await act(async () => {
render(<ImageGallery handleCopy={handleCopyMock} />);
});
const copyButtons = await waitFor(() => screen.findAllByTestId(/gallery-tab-copy-/));
await act(async () => {
fireEvent.click(copyButtons[0]);
});
expect(navigator.clipboard.writeText).toHaveBeenCalled();
});
it("should delete an image and update the gallery", async () => {
const fetchImagesMock = jest.fn().mockResolvedValue({ images: mockImages.filter((image) => image.id !== "1"), total: 2 });
(ApiService.getUserImages as jest.Mock).mockImplementation(fetchImagesMock);
await act(async () => {
render(<ImageGallery handleDelete={mockHandleDelete} />);
});
await act(async () => {
await screen.findByAltText("Image image1.jpg");
});
const deleteButtons = await waitFor(() => screen.findAllByTestId(/gallery-tab-delete-/));
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(screen.getByText("Voulez-vous supprimer cette image?")).toBeInTheDocument();
});
const confirmDeleteButton = screen.getByText("Delete");
await act(async () => {
fireEvent.click(confirmDeleteButton);
});
await waitFor(() => {
expect(ApiService.deleteImage).toHaveBeenCalledWith("1");
});
await waitFor(() => {
expect(screen.queryByAltText("Image image1.jpg")).toBeNull();
expect(screen.getByText("Image supprimée avec succès!")).toBeInTheDocument();
});
});
it("should upload an image and display success message", async () => {
const importTab = screen.getByRole("tab", { name: /import/i });
fireEvent.click(importTab);
const fileInputs = await screen.findAllByTestId("file-input");
const fileInput = fileInputs[1];
expect(fileInput).toBeInTheDocument();
const file = new File(["image"], "image.jpg", { type: "image/jpeg" });
await userEvent.upload(fileInput, file);
await waitFor(() => screen.getByAltText("Preview"));
const previewImage = screen.getByAltText("Preview");
expect(previewImage).toBeInTheDocument();
const uploadButton = screen.getByRole('button', { name: /téléverser/i });
fireEvent.click(uploadButton);
const successMessage = await screen.findByText(/téléversée avec succès/i);
expect(successMessage).toBeInTheDocument();
});
it("should close the image preview dialog when close button is clicked", async () => {
const imageCard = screen.getByAltText("Image image1.jpg");
fireEvent.click(imageCard);
const dialogImage = await screen.findByAltText("Enlarged view");
expect(dialogImage).toBeInTheDocument();
const closeButton = screen.getByTestId("close-button");
fireEvent.click(closeButton);
await waitFor(() => {
expect(screen.queryByAltText("Enlarged view")).not.toBeInTheDocument();
});
});
it("should show an error message when no file is selected", async () => {
const importTab = screen.getByRole("tab", { name: /import/i });
fireEvent.click(importTab);
const uploadButton = screen.getByRole('button', { name: /téléverser/i });
fireEvent.click(uploadButton);
await waitFor(() => {
expect(screen.getByText("Veuillez choisir une image à téléverser.")).toBeInTheDocument();
});
});
});

View file

@ -1,44 +0,0 @@
import React from "react";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import ImageGalleryModal from "../../../components/ImageGallery/ImageGalleryModal/ImageGalleryModal";
import "@testing-library/jest-dom";
jest.mock("../../../components/ImageGallery/ImageGallery", () => ({
__esModule: true,
default: jest.fn(() => <div data-testid="image-gallery" />),
}));
describe("ImageGalleryModal", () => {
it("renders button correctly", () => {
render(<ImageGalleryModal />);
const button = screen.getByLabelText(/images-open/i);
expect(button).toBeInTheDocument();
});
it("opens the modal when button is clicked", () => {
render(<ImageGalleryModal />);
const button = screen.getByRole("button", { name: /images/i });
fireEvent.click(button);
const dialog = screen.getByRole("dialog");
expect(dialog).toBeInTheDocument();
});
it("closes the modal when close button is clicked", async () => {
render(<ImageGalleryModal />);
fireEvent.click(screen.getByRole("button", { name: /images/i }));
const closeButton = screen.getByRole("button", { name: /close/i });
fireEvent.click(closeButton);
await waitFor(() => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
});
});

View file

@ -1,178 +0,0 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import LiveResults from 'src/components/LiveResults/LiveResults';
import { QuestionType } from 'src/Types/QuestionType';
import { StudentType } from 'src/Types/StudentType';
import { Socket } from 'socket.io-client';
import { BaseQuestion, parse } from 'gift-pegjs';
const mockSocket: Socket = {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
connect: jest.fn(),
disconnect: jest.fn(),
} as unknown as Socket;
const mockGiftQuestions = parse(
`::Sample Question 1:: Question stem
{
=Choice 1
=Choice 2
~Choice 3
~Choice 4
}
::Sample Question 2:: Question stem {TRUE}
`);
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
if (question.type !== "Category")
question.id = (index + 1).toString();
const newMockQuestion = question;
return { question: newMockQuestion as BaseQuestion };
});
console.log(`mockQuestions: ${JSON.stringify(mockQuestions)}`);
// each student should have a different score for the tests to pass
const mockStudents: StudentType[] = [
{ id: '1', name: 'Student 1', answers: [] },
{ id: '2', name: 'Student 2', answers: [{ idQuestion: 1, answer: ['Choice 3'], isCorrect: false }, { idQuestion: 2, answer: [true], isCorrect: true}] },
{ id: '3', name: 'Student 3', answers: [{ idQuestion: 1, answer: ['Choice 1', 'Choice 2'], isCorrect: true }, { idQuestion: 2, answer: [true], isCorrect: true}] },
];
describe('LiveResults', () => {
test('renders the component with questions and students', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
expect(screen.getByText(`Q${1}`)).toBeInTheDocument();
// Toggle the display of usernames
const toggleUsernamesSwitch = screen.getByLabelText('Afficher les noms');
// Toggle the display of usernames back
fireEvent.click(toggleUsernamesSwitch);
// Check if the component renders the students
mockStudents.forEach((student) => {
expect(screen.getByText(student.name)).toBeInTheDocument();
});
});
test('toggles the display of usernames', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
// Toggle the display of usernames
const toggleUsernamesSwitch = screen.getByLabelText('Afficher les noms');
// Toggle the display of usernames back
fireEvent.click(toggleUsernamesSwitch);
// Check if the usernames are shown again
mockStudents.forEach((student) => {
expect(screen.getByText(student.name)).toBeInTheDocument();
});
});
test('calculates and displays the correct student grades', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
// Toggle the display of usernames
const toggleUsernamesSwitch = screen.getByLabelText('Afficher les noms');
// Toggle the display of usernames back
fireEvent.click(toggleUsernamesSwitch);
// Check if the student grades are calculated and displayed correctly
const getByTextInTableCellBody = (text: string) => {
const elements = screen.getAllByText(text); // Get all elements with the specified text
return elements.find((element) => element.closest('.MuiTableCell-body')); // don't get the footer element(s)
};
mockStudents.forEach((student) => {
const grade = student.answers.filter(answer => answer.isCorrect).length / mockQuestions.length * 100;
const element = getByTextInTableCellBody(`${grade.toFixed()} %`);
expect(element).toBeInTheDocument();
});
});
test('calculates and displays the class average', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
// Toggle the display of usernames
const toggleUsernamesSwitch = screen.getByLabelText('Afficher les noms');
// Toggle the display of usernames back
fireEvent.click(toggleUsernamesSwitch);
// Calculate the class average
const totalGrades = mockStudents.reduce((total, student) => {
return total + (student.answers.filter(answer => answer.isCorrect).length / mockQuestions.length * 100);
}, 0);
const classAverage = totalGrades / mockStudents.length;
// Check if the class average is displayed correctly
const classAverageElements = screen.getAllByText(`${classAverage.toFixed()} %`);
const classAverageElement = classAverageElements.find((element) => {
return element.closest('td')?.classList.contains('MuiTableCell-footer');
});
expect(classAverageElement).toBeInTheDocument();
});
test('displays the correct answers per question', () => {
render(
<LiveResults
socket={mockSocket}
questions={mockQuestions}
showSelectedQuestion={jest.fn()}
quizMode="teacher"
students={mockStudents}
/>
);
// Check if the correct answers per question are displayed correctly
mockQuestions.forEach((_, index) => {
const correctAnswers = mockStudents.filter(student => student.answers.some(answer => answer.idQuestion === index + 1 && answer.isCorrect)).length;
const correctAnswersPercentage = (correctAnswers / mockStudents.length) * 100;
const correctAnswersElements = screen.getAllByText(`${correctAnswersPercentage.toFixed()} %`);
const correctAnswersElement = correctAnswersElements.find((element) => {
return element.closest('td')?.classList.contains('MuiTableCell-root');
});
expect(correctAnswersElement).toBeInTheDocument();
});
});
});

View file

@ -5,33 +5,23 @@ import { act } from 'react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { MultipleChoiceQuestion, parse } from 'gift-pegjs'; import { MultipleChoiceQuestion, parse } from 'gift-pegjs';
import MultipleChoiceQuestionDisplay from 'src/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay'; import MultipleChoiceQuestionDisplay from 'src/components/QuestionsDisplay/MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
const questions = parse( const questions = parse(
`::Sample Question 1:: Question stem `::Sample Question 1:: Question stem
{ {
=Choice 1 =Choice 1
~Choice 2 ~Choice 2
} }`) as MultipleChoiceQuestion[];
::Sample Question 2:: Question stem
{
=Choice 1
=Choice 2
~Choice 3
}
`) as MultipleChoiceQuestion[];
const questionWithOneCorrectChoice = questions[0]; const question = questions[0];
const questionWithMultipleCorrectChoices = questions[1];
describe('MultipleChoiceQuestionDisplay', () => { describe('MultipleChoiceQuestionDisplay', () => {
const mockHandleOnSubmitAnswer = jest.fn(); const mockHandleOnSubmitAnswer = jest.fn();
const TestWrapper = ({ showAnswer, question }: { showAnswer: boolean; question: MultipleChoiceQuestion }) => { const TestWrapper = ({ showAnswer }: { showAnswer: boolean }) => {
const [showAnswerState, setShowAnswerState] = useState(showAnswer); const [showAnswerState, setShowAnswerState] = useState(showAnswer);
const handleOnSubmitAnswer = (answer: AnswerType) => { const handleOnSubmitAnswer = (answer: string) => {
mockHandleOnSubmitAnswer(answer); mockHandleOnSubmitAnswer(answer);
setShowAnswerState(true); setShowAnswerState(true);
}; };
@ -47,51 +37,28 @@ describe('MultipleChoiceQuestionDisplay', () => {
); );
}; };
const twoChoices = questionWithOneCorrectChoice.choices; const choices = question.choices;
const threeChoices = questionWithMultipleCorrectChoices.choices;
test('renders a question (that has only one correct choice) and its choices', () => { beforeEach(() => {
render(<TestWrapper showAnswer={false} question={questionWithOneCorrectChoice} />); render(<TestWrapper showAnswer={false} />);
});
expect(screen.getByText(questionWithOneCorrectChoice.formattedStem.text)).toBeInTheDocument(); test('renders the question and choices', () => {
twoChoices.forEach((choice) => { expect(screen.getByText(question.formattedStem.text)).toBeInTheDocument();
choices.forEach((choice) => {
expect(screen.getByText(choice.formattedText.text)).toBeInTheDocument(); expect(screen.getByText(choice.formattedText.text)).toBeInTheDocument();
}); });
}); });
test('only allows one choice to be selected when question only has one correct answer', () => {
render(<TestWrapper showAnswer={false} question={questionWithOneCorrectChoice} />);
const choiceButton1 = screen.getByText('Choice 1').closest('button');
const choiceButton2 = screen.getByText('Choice 2').closest('button');
if (!choiceButton1 || !choiceButton2) throw new Error('Choice buttons not found');
// Simulate selecting multiple answers
act(() => {
fireEvent.click(choiceButton1);
});
act(() => {
fireEvent.click(choiceButton2);
});
// Verify that only the last answer is selected
expect(choiceButton1.querySelector('.answer-text.selected')).not.toBeInTheDocument();
expect(choiceButton2.querySelector('.answer-text.selected')).toBeInTheDocument();
});
test('does not submit when no answer is selected', () => { test('does not submit when no answer is selected', () => {
render(<TestWrapper showAnswer={false} question={questionWithOneCorrectChoice} />);
const submitButton = screen.getByText('Répondre'); const submitButton = screen.getByText('Répondre');
act(() => { act(() => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
}); });
expect(mockHandleOnSubmitAnswer).not.toHaveBeenCalled(); expect(mockHandleOnSubmitAnswer).not.toHaveBeenCalled();
mockHandleOnSubmitAnswer.mockClear();
}); });
test('submits the selected answer', () => { test('submits the selected answer', () => {
render(<TestWrapper showAnswer={false} question={questionWithOneCorrectChoice} />);
const choiceButton = screen.getByText('Choice 1').closest('button'); const choiceButton = screen.getByText('Choice 1').closest('button');
if (!choiceButton) throw new Error('Choice button not found'); if (!choiceButton) throw new Error('Choice button not found');
act(() => { act(() => {
@ -102,68 +69,10 @@ describe('MultipleChoiceQuestionDisplay', () => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
}); });
expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith(['Choice 1']); expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith('Choice 1');
mockHandleOnSubmitAnswer.mockClear();
});
test('renders a question (that has multiple correct choices) and its choices', () => {
render(<TestWrapper showAnswer={false} question={questionWithMultipleCorrectChoices} />);
expect(screen.getByText(questionWithMultipleCorrectChoices.formattedStem.text)).toBeInTheDocument();
threeChoices.forEach((choice) => {
expect(screen.getByText(choice.formattedText.text)).toBeInTheDocument();
});
});
test('allows multiple choices to be selected when question has multiple correct answers', () => {
render(<TestWrapper showAnswer={false} question={questionWithMultipleCorrectChoices} />);
const choiceButton1 = screen.getByText('Choice 1').closest('button');
const choiceButton2 = screen.getByText('Choice 2').closest('button');
const choiceButton3 = screen.getByText('Choice 3').closest('button');
if (!choiceButton1 || !choiceButton2 || !choiceButton3) throw new Error('Choice buttons not found');
act(() => {
fireEvent.click(choiceButton1);
});
act(() => {
fireEvent.click(choiceButton2);
});
expect(choiceButton1.querySelector('.answer-text.selected')).toBeInTheDocument();
expect(choiceButton2.querySelector('.answer-text.selected')).toBeInTheDocument();
expect(choiceButton3.querySelector('.answer-text.selected')).not.toBeInTheDocument(); // didn't click
});
test('submits multiple selected answers', () => {
render(<TestWrapper showAnswer={false} question={questionWithMultipleCorrectChoices} />);
const choiceButton1 = screen.getByText('Choice 1').closest('button');
const choiceButton2 = screen.getByText('Choice 2').closest('button');
if (!choiceButton1 || !choiceButton2) throw new Error('Choice buttons not found');
// Simulate selecting multiple answers
act(() => {
fireEvent.click(choiceButton1);
});
act(() => {
fireEvent.click(choiceButton2);
});
// Simulate submitting the answers
const submitButton = screen.getByText('Répondre');
act(() => {
fireEvent.click(submitButton);
});
// Verify that the mockHandleOnSubmitAnswer function is called with both answers
expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith(['Choice 1', 'Choice 2']);
mockHandleOnSubmitAnswer.mockClear();
}); });
it('should show ✅ next to the correct answer and ❌ next to the wrong answers when showAnswer is true', async () => { it('should show ✅ next to the correct answer and ❌ next to the wrong answers when showAnswer is true', async () => {
render(<TestWrapper showAnswer={false} question={questionWithOneCorrectChoice} />);
const choiceButton = screen.getByText('Choice 1').closest('button'); const choiceButton = screen.getByText('Choice 1').closest('button');
if (!choiceButton) throw new Error('Choice button not found'); if (!choiceButton) throw new Error('Choice button not found');
@ -179,17 +88,16 @@ describe('MultipleChoiceQuestionDisplay', () => {
}); });
// Wait for the DOM to update // Wait for the DOM to update
const correctAnswer = screen.getByText("Choice 1").closest('button'); const correctAnswer = screen.getByText("Choice 1").closest('button');
expect(correctAnswer).toBeInTheDocument(); expect(correctAnswer).toBeInTheDocument();
expect(correctAnswer?.textContent).toContain('✅'); expect(correctAnswer?.textContent).toContain('✅');
const wrongAnswer1 = screen.getByText("Choice 2").closest('button'); const wrongAnswer1 = screen.getByText("Choice 2").closest('button');
expect(wrongAnswer1).toBeInTheDocument(); expect(wrongAnswer1).toBeInTheDocument();
expect(wrongAnswer1?.textContent).toContain('❌'); expect(wrongAnswer1?.textContent).toContain('❌');
}); });
it('should not show ✅ or ❌ when Répondre button is not clicked', async () => { it('should not show ✅ or ❌ when repondre button is not clicked', async () => {
render(<TestWrapper showAnswer={false} question={questionWithOneCorrectChoice} />);
const choiceButton = screen.getByText('Choice 1').closest('button'); const choiceButton = screen.getByText('Choice 1').closest('button');
if (!choiceButton) throw new Error('Choice button not found'); if (!choiceButton) throw new Error('Choice button not found');
@ -209,5 +117,5 @@ describe('MultipleChoiceQuestionDisplay', () => {
expect(wrongAnswer1?.textContent).not.toContain('❌'); expect(wrongAnswer1?.textContent).not.toContain('❌');
}); });
}); });

View file

@ -67,7 +67,6 @@ describe('NumericalQuestion Component', () => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(mockHandleOnSubmitAnswer).not.toHaveBeenCalled(); expect(mockHandleOnSubmitAnswer).not.toHaveBeenCalled();
mockHandleOnSubmitAnswer.mockClear();
}); });
it('submits answer correctly', () => { it('submits answer correctly', () => {
@ -78,7 +77,6 @@ describe('NumericalQuestion Component', () => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith([7]); expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith(7);
mockHandleOnSubmitAnswer.mockClear();
}); });
}); });

View file

@ -29,24 +29,23 @@ describe('Questions Component', () => {
render(<QuestionDisplay question={question} {...sampleProps} />); render(<QuestionDisplay question={question} {...sampleProps} />);
}; };
// describe('question type parsing', () => { describe('question type parsing', () => {
// it('parses true/false question type correctly', () => { it('parses true/false question type correctly', () => {
// expect(sampleTrueFalseQuestion.type).toBe('TF'); expect(sampleTrueFalseQuestion.type).toBe('TF');
// }); });
// it('parses multiple choice question type correctly', () => { it('parses multiple choice question type correctly', () => {
// expect(sampleMultipleChoiceQuestion.type).toBe('MC'); expect(sampleMultipleChoiceQuestion.type).toBe('MC');
// }); });
// it('parses numerical question type correctly', () => { it('parses numerical question type correctly', () => {
// expect(sampleNumericalQuestion.type).toBe('Numerical'); expect(sampleNumericalQuestion.type).toBe('Numerical');
// }); });
// it('parses short answer question type correctly', () => {
// expect(sampleShortAnswerQuestion.type).toBe('Short');
// });
// });
it('parses short answer question type correctly', () => {
expect(sampleShortAnswerQuestion.type).toBe('Short');
});
});
it('renders correctly for True/False question', () => { it('renders correctly for True/False question', () => {
renderComponent(sampleTrueFalseQuestion); renderComponent(sampleTrueFalseQuestion);
@ -74,8 +73,7 @@ describe('Questions Component', () => {
const submitButton = screen.getByText('Répondre'); const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(['Choice 1']); expect(mockHandleSubmitAnswer).toHaveBeenCalledWith('Choice 1');
mockHandleSubmitAnswer.mockClear();
}); });
it('renders correctly for Numerical question', () => { it('renders correctly for Numerical question', () => {
@ -95,8 +93,7 @@ describe('Questions Component', () => {
const submitButton = screen.getByText('Répondre'); const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith([7]); expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(7);
mockHandleSubmitAnswer.mockClear();
}); });
it('renders correctly for Short Answer question', () => { it('renders correctly for Short Answer question', () => {
@ -120,7 +117,7 @@ describe('Questions Component', () => {
const submitButton = screen.getByText('Répondre'); const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(['User Input']); expect(mockHandleSubmitAnswer).toHaveBeenCalledWith('User Input');
}); });
}); });

View file

@ -47,7 +47,6 @@ describe('ShortAnswerQuestion Component', () => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).not.toHaveBeenCalled(); expect(mockHandleSubmitAnswer).not.toHaveBeenCalled();
mockHandleSubmitAnswer.mockClear();
}); });
it('submits answer correctly', () => { it('submits answer correctly', () => {
@ -61,7 +60,6 @@ describe('ShortAnswerQuestion Component', () => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(['User Input']); expect(mockHandleSubmitAnswer).toHaveBeenCalledWith('User Input');
mockHandleSubmitAnswer.mockClear();
}); });
}); });

View file

@ -5,7 +5,6 @@ import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import TrueFalseQuestionDisplay from 'src/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay'; import TrueFalseQuestionDisplay from 'src/components/QuestionsDisplay/TrueFalseQuestionDisplay/TrueFalseQuestionDisplay';
import { parse, TrueFalseQuestion } from 'gift-pegjs'; import { parse, TrueFalseQuestion } from 'gift-pegjs';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
describe('TrueFalseQuestion Component', () => { describe('TrueFalseQuestion Component', () => {
const mockHandleSubmitAnswer = jest.fn(); const mockHandleSubmitAnswer = jest.fn();
@ -17,7 +16,7 @@ describe('TrueFalseQuestion Component', () => {
const TestWrapper = ({ showAnswer }: { showAnswer: boolean }) => { const TestWrapper = ({ showAnswer }: { showAnswer: boolean }) => {
const [showAnswerState, setShowAnswerState] = useState(showAnswer); const [showAnswerState, setShowAnswerState] = useState(showAnswer);
const handleOnSubmitAnswer = (answer: AnswerType) => { const handleOnSubmitAnswer = (answer: boolean) => {
mockHandleSubmitAnswer(answer); mockHandleSubmitAnswer(answer);
setShowAnswerState(true); setShowAnswerState(true);
}; };
@ -56,7 +55,6 @@ describe('TrueFalseQuestion Component', () => {
}); });
expect(mockHandleSubmitAnswer).not.toHaveBeenCalled(); expect(mockHandleSubmitAnswer).not.toHaveBeenCalled();
mockHandleSubmitAnswer.mockClear();
}); });
it('submits answer correctly for True', () => { it('submits answer correctly for True', () => {
@ -71,8 +69,7 @@ describe('TrueFalseQuestion Component', () => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
}); });
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith([true]); expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(true);
mockHandleSubmitAnswer.mockClear();
}); });
it('submits answer correctly for False', () => { it('submits answer correctly for False', () => {
@ -85,8 +82,7 @@ describe('TrueFalseQuestion Component', () => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
}); });
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith([false]); expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(false);
mockHandleSubmitAnswer.mockClear();
}); });
@ -115,7 +111,7 @@ describe('TrueFalseQuestion Component', () => {
expect(wrongAnswer1?.textContent).toContain('❌'); expect(wrongAnswer1?.textContent).toContain('❌');
}); });
it('should not show ✅ or ❌ when pondre button is not clicked', async () => { it('should not show ✅ or ❌ when repondre button is not clicked', async () => {
const choiceButton = screen.getByText('Vrai').closest('button'); const choiceButton = screen.getByText('Vrai').closest('button');
if (!choiceButton) throw new Error('Choice button not found'); if (!choiceButton) throw new Error('Choice button not found');

View file

@ -1,81 +0,0 @@
import React from 'react';
import { render, screen, fireEvent, act } from '@testing-library/react';
import ShareQuizModal from '../../../components/ShareQuizModal/ShareQuizModal.tsx';
import { QuizType } from '../../../Types/QuizType';
import '@testing-library/jest-dom';
describe('ShareQuizModal', () => {
const mockQuiz: QuizType = {
_id: '123',
folderId: 'folder-123',
folderName: 'Test Folder',
userId: 'user-123',
title: 'Test Quiz',
content: ['Question 1', 'Question 2'],
created_at: new Date(),
updated_at: new Date(),
};
beforeAll(() => {
// Properly mock the clipboard API
Object.defineProperty(navigator, 'clipboard', {
value: {
writeText: jest.fn().mockImplementation(() => Promise.resolve()),
},
writable: true,
});
});
afterEach(() => {
jest.clearAllMocks();
});
it('renders the share button', () => {
render(<ShareQuizModal quiz={mockQuiz} />);
expect(screen.getByLabelText('partager quiz')).toBeInTheDocument();
expect(screen.getByTestId('ShareIcon')).toBeInTheDocument();
});
it('copies the quiz URL to clipboard when share button is clicked', async () => {
render(<ShareQuizModal quiz={mockQuiz} />);
const shareButton = screen.getByLabelText('partager quiz');
await act(async () => {
fireEvent.click(shareButton);
});
expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
`${window.location.origin}/teacher/share/${mockQuiz._id}`
);
// Check for feedback dialog content
expect(screen.getByText(/L'URL de partage pour le quiz/i)).toBeInTheDocument();
expect(screen.getByText(mockQuiz.title)).toBeInTheDocument();
expect(screen.getByText(/a été copiée\./i)).toBeInTheDocument();
});
it('shows error message when clipboard write fails', async () => {
// Override the mock to reject
(navigator.clipboard.writeText as jest.Mock).mockRejectedValueOnce(new Error('Clipboard write failed'));
render(<ShareQuizModal quiz={mockQuiz} />);
const shareButton = screen.getByLabelText('partager quiz');
await act(async () => {
fireEvent.click(shareButton);
});
expect(screen.getByText(/Une erreur est survenue lors de la copie de l'URL\./i)).toBeInTheDocument();
});
it('displays the quiz title in the success message', async () => {
render(<ShareQuizModal quiz={mockQuiz} />);
const shareButton = screen.getByLabelText('partager quiz');
await act(async () => {
fireEvent.click(shareButton);
});
expect(screen.getByText(mockQuiz.title)).toBeInTheDocument();
});
});

View file

@ -10,15 +10,14 @@ describe('StudentWaitPage Component', () => {
{ id: '1', name: 'User1', answers: new Array<Answer>() }, { id: '1', name: 'User1', answers: new Array<Answer>() },
{ id: '2', name: 'User2', answers: new Array<Answer>() }, { id: '2', name: 'User2', answers: new Array<Answer>() },
{ id: '3', name: 'User3', answers: new Array<Answer>() }, { id: '3', name: 'User3', answers: new Array<Answer>() },
]; ];
const mockProps = { const mockProps = {
students: mockUsers, students: mockUsers,
launchQuiz: jest.fn(), launchQuiz: jest.fn(),
roomName: 'Test Room', roomName: 'Test Room',
setQuizMode: jest.fn(), setQuizMode: jest.fn(),
setIsRoomSelectionVisible: jest.fn() };
};
test('renders StudentWaitPage with correct content', () => { test('renders StudentWaitPage with correct content', () => {
render(<StudentWaitPage {...mockProps} />); render(<StudentWaitPage {...mockProps} />);
@ -29,15 +28,16 @@ describe('StudentWaitPage Component', () => {
expect(launchButton).toBeInTheDocument(); expect(launchButton).toBeInTheDocument();
mockUsers.forEach((user) => { mockUsers.forEach((user) => {
expect(screen.getByText(user.name)).toBeInTheDocument(); expect(screen.getByText(user.name)).toBeInTheDocument();
}); });
}); });
test('clicking on "Lancer" button opens LaunchQuizDialog', () => { test('clicking on "Lancer" button opens LaunchQuizDialog', () => {
render(<StudentWaitPage {...mockProps} />); render(<StudentWaitPage {...mockProps} />);
fireEvent.click(screen.getByRole('button', { name: /Lancer/i })); fireEvent.click(screen.getByRole('button', { name: /Lancer/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
}); });
});
})

View file

@ -1,359 +0,0 @@
import { checkIfIsCorrect } from 'src/pages/Teacher/ManageRoom/useRooms';
import { HighLowNumericalAnswer, MultipleChoiceQuestion, MultipleNumericalAnswer, NumericalQuestion, RangeNumericalAnswer, ShortAnswerQuestion, SimpleNumericalAnswer, TrueFalseQuestion } from 'gift-pegjs';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
import { QuestionType } from 'src/Types/QuestionType';
describe('checkIfIsCorrect', () => {
const mockQuestions: QuestionType[] = [
{
question: {
id: '1',
type: 'MC',
choices: [
{ isCorrect: true, formattedText: { text: 'Answer1' } },
{ isCorrect: true, formattedText: { text: 'Answer2' } },
{ isCorrect: false, formattedText: { text: 'Answer3' } },
],
} as MultipleChoiceQuestion,
},
];
test('returns true when all selected answers are correct', () => {
const answer: AnswerType = ['Answer1', 'Answer2'];
const result = checkIfIsCorrect(answer, 1, mockQuestions);
expect(result).toBe(true);
});
test('returns false when some selected answers are incorrect', () => {
const answer: AnswerType = ['Answer1', 'Answer3'];
const result = checkIfIsCorrect(answer, 1, mockQuestions);
expect(result).toBe(false);
});
test('returns false when all correct answers are selected, but one incorrect is also selected', () => {
const answer: AnswerType = ['Answer1', 'Answer2', 'Answer3'];
const result = checkIfIsCorrect(answer, 1, mockQuestions);
expect(result).toBe(false);
});
test('returns false when no answers are selected', () => {
const answer: AnswerType = [];
const result = checkIfIsCorrect(answer, 1, mockQuestions);
expect(result).toBe(false);
});
test('returns false when no correct answers are provided in the question', () => {
const mockQuestionsWithNoCorrectAnswers: QuestionType[] = [
{
question: {
id: '1',
type: 'MC',
choices: [
{ isCorrect: false, formattedText: { text: 'Answer1' } },
{ isCorrect: false, formattedText: { text: 'Answer2' } },
],
} as MultipleChoiceQuestion,
},
];
const answer: AnswerType = ['Answer1'];
const result = checkIfIsCorrect(answer, 1, mockQuestionsWithNoCorrectAnswers);
expect(result).toBe(false);
});
test('returns true for a correct true/false answer', () => {
const mockQuestionsTF: QuestionType[] = [
{
question: {
id: '2',
type: 'TF',
isTrue: true,
} as TrueFalseQuestion,
},
];
const answer: AnswerType = [true];
const result = checkIfIsCorrect(answer, 2, mockQuestionsTF);
expect(result).toBe(true);
});
test('returns false for an incorrect true/false answer', () => {
const mockQuestionsTF: QuestionType[] = [
{
question: {
id: '2',
type: 'TF',
isTrue: true,
} as TrueFalseQuestion,
},
];
const answer: AnswerType = [false];
const result = checkIfIsCorrect(answer, 2, mockQuestionsTF);
expect(result).toBe(false);
});
test('returns false for a true/false question with no answer', () => {
const mockQuestionsTF: QuestionType[] = [
{
question: {
id: '2',
type: 'TF',
isTrue: true,
} as TrueFalseQuestion,
},
];
const answer: AnswerType = [];
const result = checkIfIsCorrect(answer, 2, mockQuestionsTF);
expect(result).toBe(false);
});
test('returns true for a correct true/false answer when isTrue is false', () => {
const mockQuestionsTF: QuestionType[] = [
{
question: {
id: '3',
type: 'TF',
isTrue: false, // Correct answer is false
} as TrueFalseQuestion,
},
];
const answer: AnswerType = [false];
const result = checkIfIsCorrect(answer, 3, mockQuestionsTF);
expect(result).toBe(true);
});
test('returns false for an incorrect true/false answer when isTrue is false', () => {
const mockQuestionsTF: QuestionType[] = [
{
question: {
id: '3',
type: 'TF',
isTrue: false, // Correct answer is false
} as TrueFalseQuestion,
},
];
const answer: AnswerType = [true];
const result = checkIfIsCorrect(answer, 3, mockQuestionsTF);
expect(result).toBe(false);
});
test('returns true for a correct short answer', () => {
const mockQuestionsShort: QuestionType[] = [
{
question: {
id: '4',
type: 'Short',
choices: [
{ text: 'CorrectAnswer1' },
{ text: 'CorrectAnswer2' },
],
} as ShortAnswerQuestion,
},
];
const answer: AnswerType = ['CorrectAnswer1'];
const result = checkIfIsCorrect(answer, 4, mockQuestionsShort);
expect(result).toBe(true);
});
test('returns false for an incorrect short answer', () => {
const mockQuestionsShort: QuestionType[] = [
{
question: {
id: '4',
type: 'Short',
choices: [
{ text: 'CorrectAnswer1' },
{ text: 'CorrectAnswer2' },
],
} as ShortAnswerQuestion,
},
];
const answer: AnswerType = ['WrongAnswer'];
const result = checkIfIsCorrect(answer, 4, mockQuestionsShort);
expect(result).toBe(false);
});
test('returns true for a correct short answer with case insensitivity', () => {
const mockQuestionsShort: QuestionType[] = [
{
question: {
id: '4',
type: 'Short',
choices: [
{ text: 'CorrectAnswer1' },
{ text: 'CorrectAnswer2' },
],
} as ShortAnswerQuestion,
},
];
const answer: AnswerType = ['correctanswer1']; // Lowercase version of the correct answer
const result = checkIfIsCorrect(answer, 4, mockQuestionsShort);
expect(result).toBe(true);
});
test('returns false for a short answer question with no answer', () => {
const mockQuestionsShort: QuestionType[] = [
{
question: {
id: '4',
type: 'Short',
choices: [
{ text: 'CorrectAnswer1' },
{ text: 'CorrectAnswer2' },
],
} as ShortAnswerQuestion,
},
];
const answer: AnswerType = [];
const result = checkIfIsCorrect(answer, 4, mockQuestionsShort);
expect(result).toBe(false);
});
test('returns true for a correct simple numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '5',
type: 'Numerical',
choices: [
{ type: 'simple', number: 42 } as SimpleNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [42]; // User's answer
const result = checkIfIsCorrect(answer, 5, mockQuestionsNumerical);
expect(result).toBe(true);
});
test('returns false for an incorrect simple numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '5',
type: 'Numerical',
choices: [
{ type: 'simple', number: 42 } as SimpleNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [43]; // User's answer
const result = checkIfIsCorrect(answer, 5, mockQuestionsNumerical);
expect(result).toBe(false);
});
test('returns true for a correct range numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '6',
type: 'Numerical',
choices: [
{ type: 'range', number: 50, range: 5 } as RangeNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [52]; // User's answer within the range (50 ± 5)
const result = checkIfIsCorrect(answer, 6, mockQuestionsNumerical);
expect(result).toBe(true);
});
test('returns false for an out-of-range numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '6',
type: 'Numerical',
choices: [
{ type: 'range', number: 50, range: 5 } as RangeNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [56]; // User's answer outside the range (50 ± 5)
const result = checkIfIsCorrect(answer, 6, mockQuestionsNumerical);
expect(result).toBe(false);
});
test('returns true for a correct high-low numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '7',
type: 'Numerical',
choices: [
{ type: 'high-low', numberHigh: 100, numberLow: 90 } as HighLowNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [95]; // User's answer within the range (90 to 100)
const result = checkIfIsCorrect(answer, 7, mockQuestionsNumerical);
expect(result).toBe(true);
});
test('returns false for an out-of-range high-low numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '7',
type: 'Numerical',
choices: [
{ type: 'high-low', numberHigh: 100, numberLow: 90 } as HighLowNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [105]; // User's answer outside the range (90 to 100)
const result = checkIfIsCorrect(answer, 7, mockQuestionsNumerical);
expect(result).toBe(false);
});
test('returns true for a correct multiple numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '8',
type: 'Numerical',
choices: [
{
isCorrect: true,
answer: { type: 'simple', number: 42 } as SimpleNumericalAnswer,
} as MultipleNumericalAnswer,
{
isCorrect: false,
answer: { type: 'high-low', numberHigh: 100, numberLow: 90 } as HighLowNumericalAnswer,
formattedFeedback: { text: 'You guessed way too high' },
}
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [42]; // User's answer matches the correct multiple numerical answer
const result = checkIfIsCorrect(answer, 8, mockQuestionsNumerical);
expect(result).toBe(true);
});
test('returns false for an incorrect multiple numerical answer', () => {
const mockQuestionsNumerical: QuestionType[] = [
{
question: {
id: '8',
type: 'Numerical',
choices: [
{
type: 'multiple',
isCorrect: true,
answer: { type: 'simple', number: 42 } as SimpleNumericalAnswer,
} as MultipleNumericalAnswer,
],
} as NumericalQuestion,
},
];
const answer: AnswerType = [43]; // User's answer does not match the correct multiple numerical answer
const result = checkIfIsCorrect(answer, 8, mockQuestionsNumerical);
expect(result).toBe(false);
});
});

View file

@ -1,382 +0,0 @@
import React from 'react';
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter, useNavigate, useParams } from 'react-router-dom';
import ManageRoom from 'src/pages/Teacher/ManageRoom/ManageRoom';
import { StudentType } from 'src/Types/StudentType';
import { QuizType } from 'src/Types/QuizType';
import webSocketService, { AnswerReceptionFromBackendType } from 'src/services/WebsocketService';
import ApiService from 'src/services/ApiService';
import { Socket } from 'socket.io-client';
import { RoomProvider } from 'src/pages/Teacher/ManageRoom/RoomContext';
jest.mock('src/services/WebsocketService');
jest.mock('src/services/ApiService');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
useParams: jest.fn(),
}));
jest.mock('src/pages/Teacher/ManageRoom/RoomContext');
jest.mock('qrcode.react', () => ({
__esModule: true,
QRCodeCanvas: ({ value }: { value: string }) => <div data-testid="qr-code">{value}</div>,
}));
const mockSocket = {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
connect: jest.fn(),
disconnect: jest.fn(),
} as unknown as Socket;
const mockQuiz: QuizType = {
_id: 'test-quiz-id',
title: 'Test Quiz',
content: ['::Q1:: Question 1 { =Answer1 ~Answer2 }', '::Q2:: Question 2 { =Answer1 ~Answer2 }'],
folderId: 'folder-id',
folderName: 'folder-name',
userId: 'user-id',
created_at: new Date(),
updated_at: new Date(),
};
const mockStudents: StudentType[] = [
{ id: '1', name: 'Student 1', answers: [] },
{ id: '2', name: 'Student 2', answers: [] },
];
const mockAnswerData: AnswerReceptionFromBackendType = {
answer: ['Answer1'],
idQuestion: 1,
idUser: '1',
username: 'Student 1',
};
describe('ManageRoom', () => {
const navigate = jest.fn();
const useParamsMock = useParams as jest.Mock;
const mockSetSelectedRoom = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(useNavigate as jest.Mock).mockReturnValue(navigate);
useParamsMock.mockReturnValue({ quizId: 'test-quiz-id', roomName: 'Test Room' });
(ApiService.getQuiz as jest.Mock).mockResolvedValue(mockQuiz);
(webSocketService.connect as jest.Mock).mockReturnValue(mockSocket);
(RoomProvider as jest.Mock).mockReturnValue({
selectedRoom: { id: '1', title: 'Test Room' },
setSelectedRoom: mockSetSelectedRoom,
});
});
test('prepares to launch quiz and fetches quiz data', async () => {
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('Test Room');
});
await waitFor(() => {
expect(ApiService.getQuiz).toHaveBeenCalledWith('test-quiz-id');
});
const launchButton = screen.getByText('Lancer');
fireEvent.click(launchButton);
const rythmeButton = screen.getByText('Rythme du professeur');
fireEvent.click(rythmeButton);
const secondLaunchButton = screen.getAllByText('Lancer');
fireEvent.click(secondLaunchButton[1]);
await waitFor(() => {
expect(screen.getByText('Test Quiz')).toBeInTheDocument();
const roomHeader = document.querySelector('h1');
expect(roomHeader).toHaveTextContent('Salle : TEST ROOM');
expect(screen.getByText('0/60')).toBeInTheDocument();
expect(screen.getByText('Question 1/2')).toBeInTheDocument();
});
});
test('handles create-success event', async () => {
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('Test Room');
});
await waitFor(() => {
expect(screen.getByText(/Salle\s*:\s*Test Room/i)).toBeInTheDocument();
});
});
test('handles user-joined event', async () => {
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('Test Room');
});
await act(async () => {
const userJoinedCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'user-joined')[1];
userJoinedCallback(mockStudents[0]);
});
await waitFor(() => {
expect(screen.getByText('Student 1')).toBeInTheDocument();
});
const launchButton = screen.getByText('Lancer');
fireEvent.click(launchButton);
const rythmeButton = screen.getByText('Rythme du professeur');
fireEvent.click(rythmeButton);
const secondLaunchButton = screen.getAllByText('Lancer');
fireEvent.click(secondLaunchButton[1]);
await waitFor(() => {
expect(screen.getByText('1/60')).toBeInTheDocument();
});
});
test('handles next question', async () => {
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('Test Room');
});
fireEvent.click(screen.getByText('Lancer'));
fireEvent.click(screen.getByText('Rythme du professeur'));
fireEvent.click(screen.getAllByText('Lancer')[1]);
await waitFor(() => {
screen.debug();
});
const nextQuestionButton = await screen.findByRole('button', { name: /Prochaine question/i });
expect(nextQuestionButton).toBeInTheDocument();
fireEvent.click(nextQuestionButton);
await waitFor(() => {
expect(screen.getByText('Question 2/2')).toBeInTheDocument();
});
});
test('handles disconnect', async () => {
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('Test Room');
});
const disconnectButton = screen.getByText('Quitter');
fireEvent.click(disconnectButton);
const confirmButton = screen.getAllByText('Confirmer');
fireEvent.click(confirmButton[1]);
await waitFor(() => {
expect(webSocketService.disconnect).toHaveBeenCalled();
expect(navigate).toHaveBeenCalledWith('/teacher/dashboard');
});
});
test('handles submit-answer-room event', async () => {
const consoleSpy = jest.spyOn(console, 'log');
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('Test Room');
});
const launchButton = screen.getByText('Lancer');
fireEvent.click(launchButton);
const rythmeButton = screen.getByText('Rythme du professeur');
fireEvent.click(rythmeButton);
const secondLaunchButton = screen.getAllByText('Lancer');
fireEvent.click(secondLaunchButton[1]);
await act(async () => {
const userJoinedCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'user-joined')[1];
userJoinedCallback(mockStudents[0]);
});
await act(async () => {
const submitAnswerCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'submit-answer-room')[1];
submitAnswerCallback(mockAnswerData);
});
await waitFor(() => {
// console.info(consoleSpy.mock.calls);
expect(consoleSpy).toHaveBeenCalledWith(
'Received answer from Student 1 for question 1: Answer1'
);
});
consoleSpy.mockRestore();
});
test('vide la liste des étudiants après déconnexion', async () => {
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('Test Room');
});
await act(async () => {
const userJoinedCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'user-joined')[1];
userJoinedCallback(mockStudents[0]);
});
const disconnectButton = screen.getByText('Quitter');
fireEvent.click(disconnectButton);
const confirmButton = screen.getAllByText('Confirmer');
fireEvent.click(confirmButton[1]);
await waitFor(() => {
expect(screen.queryByText('Student 1')).not.toBeInTheDocument();
});
});
test('terminates the quiz and navigates to teacher dashboard when the "Terminer le quiz" button is clicked', async () => {
await act(async () => {
render(
<MemoryRouter>
<ManageRoom />
</MemoryRouter>
);
});
await act(async () => {
const createSuccessCallback = (mockSocket.on as jest.Mock).mock.calls.find(call => call[0] === 'create-success')[1];
createSuccessCallback('Test Room');
});
fireEvent.click(screen.getByText('Lancer'));
fireEvent.click(screen.getByText('Rythme du professeur'));
fireEvent.click(screen.getAllByText('Lancer')[1]);
await waitFor(() => {
expect(screen.getByText('Test Quiz')).toBeInTheDocument();
});
const finishQuizButton = screen.getByText('Terminer le quiz');
fireEvent.click(finishQuizButton);
await waitFor(() => {
expect(navigate).toHaveBeenCalledWith('/teacher/dashboard');
});
});
test("Affiche la modale QR Code lorsquon clique sur le bouton", async () => {
render(<MemoryRouter><ManageRoom /></MemoryRouter>);
const button = screen.getByRole('button', { name: /lien de participation/i });
fireEvent.click(button);
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
expect(screen.getByRole('heading', { name: /Rejoindre la salle/i })).toBeInTheDocument();
expect(screen.getByText(/Scannez ce QR code ou partagez le lien ci-dessous/i)).toBeInTheDocument();
expect(screen.getByTestId('qr-code')).toBeInTheDocument();
});
test("Ferme la modale QR Code lorsquon clique sur le bouton Fermer", async () => {
render(<MemoryRouter><ManageRoom /></MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: /lien de participation/i }));
await waitFor(() => {
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: /fermer/i }));
await waitFor(() => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
test('Affiche le bon lien de participation', () => {
render(<MemoryRouter><ManageRoom /></MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: /lien de participation/i }));
const roomUrl = `${window.location.origin}/student/join-room?roomName=Test Room`;
expect(screen.getByTestId('qr-code')).toHaveTextContent(roomUrl);
});
test('Vérifie que le QR code contient la bonne URL', () => {
render(<MemoryRouter><ManageRoom /></MemoryRouter>);
fireEvent.click(screen.getByRole('button', { name: /lien de participation/i }));
const roomUrl = `${window.location.origin}/student/join-room?roomName=Test Room`;
expect(screen.getByTestId('qr-code')).toHaveTextContent(roomUrl);
});
});

View file

@ -5,10 +5,9 @@ import { MemoryRouter } from 'react-router-dom';
import StudentModeQuiz from 'src/components/StudentModeQuiz/StudentModeQuiz'; import StudentModeQuiz from 'src/components/StudentModeQuiz/StudentModeQuiz';
import { BaseQuestion, parse } from 'gift-pegjs'; import { BaseQuestion, parse } from 'gift-pegjs';
import { QuestionType } from 'src/Types/QuestionType'; import { QuestionType } from 'src/Types/QuestionType';
import { AnswerSubmissionToBackendType } from 'src/services/WebsocketService';
const mockGiftQuestions = parse( const mockGiftQuestions = parse(
`::Sample Question 1:: Sample Question 1 {=Option A =Option B ~Option C} `::Sample Question 1:: Sample Question 1 {=Option A ~Option B}
::Sample Question 2:: Sample Question 2 {T}`); ::Sample Question 2:: Sample Question 2 {T}`);
@ -16,7 +15,7 @@ const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) =>
if (question.type !== "Category") if (question.type !== "Category")
question.id = (index + 1).toString(); question.id = (index + 1).toString();
const newMockQuestion = question; const newMockQuestion = question;
return { question: newMockQuestion as BaseQuestion }; return {question : newMockQuestion as BaseQuestion};
}); });
const mockSubmitAnswer = jest.fn(); const mockSubmitAnswer = jest.fn();
@ -27,12 +26,10 @@ beforeEach(() => {
<MemoryRouter> <MemoryRouter>
<StudentModeQuiz <StudentModeQuiz
questions={mockQuestions} questions={mockQuestions}
answers={Array(mockQuestions.length).fill({} as AnswerSubmissionToBackendType)}
submitAnswer={mockSubmitAnswer} submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket} disconnectWebSocket={mockDisconnectWebSocket}
/> />
</MemoryRouter> </MemoryRouter>);
);
}); });
describe('StudentModeQuiz', () => { describe('StudentModeQuiz', () => {
@ -51,50 +48,7 @@ describe('StudentModeQuiz', () => {
fireEvent.click(screen.getByText('Répondre')); fireEvent.click(screen.getByText('Répondre'));
}); });
expect(mockSubmitAnswer).toHaveBeenCalledWith(['Option A'], 1); expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1);
});
test('handles shows feedback for an already answered question', async () => {
// Answer the first question
act(() => {
fireEvent.click(screen.getByText('Option A'));
});
act(() => {
fireEvent.click(screen.getByText('Répondre'));
});
expect(mockSubmitAnswer).toHaveBeenCalledWith(['Option A'], 1);
const firstButtonA = screen.getByRole("button", {name: '✅ A Option A'});
expect(firstButtonA).toBeInTheDocument();
expect(firstButtonA.querySelector('.selected')).toBeInTheDocument();
expect(screen.getByRole("button", {name: '✅ B Option B'})).toBeInTheDocument();
expect(screen.queryByText('Répondre')).not.toBeInTheDocument();
// Navigate to the next question
act(() => {
fireEvent.click(screen.getByText('Question suivante'));
});
expect(screen.getByText('Sample Question 2')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
// Navigate back to the first question
act(() => {
fireEvent.click(screen.getByText('Question précédente'));
});
expect(await screen.findByText('Sample Question 1')).toBeInTheDocument();
// Since answers are mocked, it doesn't recognize the question as already answered
// TODO these tests are partially faked, need to be fixed if we can mock the answers
// const buttonA = screen.getByRole("button", {name: '✅ A Option A'});
const buttonA = screen.getByRole("button", {name: 'A Option A'});
expect(buttonA).toBeInTheDocument();
// const buttonB = screen.getByRole("button", {name: '✅ B Option B'});
const buttonB = screen.getByRole("button", {name: 'B Option B'});
expect(buttonB).toBeInTheDocument();
// // "Option A" div inside the name of button should have selected class
// expect(buttonA.querySelector('.selected')).toBeInTheDocument();
}); });
test('handles quit button click', async () => { test('handles quit button click', async () => {
@ -111,38 +65,16 @@ describe('StudentModeQuiz', () => {
}); });
act(() => { act(() => {
fireEvent.click(screen.getByText('Répondre')); fireEvent.click(screen.getByText('Répondre'));
}); });
act(() => { act(() => {
fireEvent.click(screen.getByText('Question suivante')); fireEvent.click(screen.getByText('Question suivante'));
}); });
expect(screen.getByText('Sample Question 2')).toBeInTheDocument(); const sampleQuestionElements = screen.queryAllByText(/Sample question 2/i);
expect(screen.getByText('Répondre')).toBeInTheDocument(); expect(sampleQuestionElements.length).toBeGreaterThan(0);
expect(screen.getByText('V')).toBeInTheDocument();
}); });
// le test suivant est fait dans MultipleChoiceQuestionDisplay.test.tsx
// test('allows multiple answers to be selected for a question', async () => {
// // Simulate selecting multiple answers
// act(() => {
// fireEvent.click(screen.getByText('Option A'));
// });
// act(() => {
// fireEvent.click(screen.getByText('Option B'));
// });
// // Simulate submitting the answers
// act(() => {
// fireEvent.click(screen.getByText('Répondre'));
// });
// // Verify that the mockSubmitAnswer function is called with both answers
// expect(mockSubmitAnswer).toHaveBeenCalledWith(['Option A', 'Option B'], 1);
// // Verify that the selected answers are displayed as selected
// const buttonA = screen.getByRole('button', { name: '✅ A Option A' });
// const buttonB = screen.getByRole('button', { name: '✅ B Option B' });
// expect(buttonA).toBeInTheDocument();
// expect(buttonB).toBeInTheDocument();
// });
}); });

View file

@ -3,52 +3,41 @@ import React from 'react';
import { render, fireEvent, act } from '@testing-library/react'; import { render, fireEvent, act } from '@testing-library/react';
import { screen } from '@testing-library/dom'; import { screen } from '@testing-library/dom';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { BaseQuestion, MultipleChoiceQuestion, parse } from 'gift-pegjs'; import { MultipleChoiceQuestion, parse } from 'gift-pegjs';
import TeacherModeQuiz from 'src/components/TeacherModeQuiz/TeacherModeQuiz'; import TeacherModeQuiz from 'src/components/TeacherModeQuiz/TeacherModeQuiz';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { QuestionType } from 'src/Types/QuestionType'; // import { mock } from 'node:test';
import { AnswerSubmissionToBackendType } from 'src/services/WebsocketService';
const mockGiftQuestions = parse( const mockGiftQuestions = parse(
`::Sample Question 1:: Sample Question 1 {=Option A ~Option B} `::Sample Question:: Sample Question {=Option A ~Option B}`);
::Sample Question 2:: Sample Question 2 {=Option A ~Option B}`);
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
if (question.type !== "Category") describe('TeacherModeQuiz', () => {
question.id = (index + 1).toString(); it ('renders the initial question as MultipleChoiceQuestion', () => {
const newMockQuestion = question; expect(mockGiftQuestions[0].type).toBe('MC');
return {question : newMockQuestion as BaseQuestion};
}); });
describe('TeacherModeQuiz', () => {
let mockQuestion = mockQuestions[0].question as MultipleChoiceQuestion; const mockQuestion = mockGiftQuestions[0] as MultipleChoiceQuestion;
mockQuestion.id = '1'; mockQuestion.id = '1';
const mockSubmitAnswer = jest.fn(); const mockSubmitAnswer = jest.fn();
const mockDisconnectWebSocket = jest.fn(); const mockDisconnectWebSocket = jest.fn();
let rerender: (ui: React.ReactElement) => void;
beforeEach(async () => { beforeEach(async () => {
const utils = render( render(
<MemoryRouter> <MemoryRouter>
<TeacherModeQuiz <TeacherModeQuiz
questionInfos={{ question: mockQuestion }} questionInfos={{ question: mockQuestion }}
answers={Array(mockQuestions.length).fill({} as AnswerSubmissionToBackendType)}
submitAnswer={mockSubmitAnswer} submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket} /> disconnectWebSocket={mockDisconnectWebSocket} />
</MemoryRouter> </MemoryRouter>
); );
rerender = utils.rerender;
}); });
test('renders the initial question', () => { test('renders the initial question', () => {
expect(screen.getByText('Question 1')).toBeInTheDocument(); expect(screen.getByText('Question 1')).toBeInTheDocument();
expect(screen.getByText('Sample Question 1')).toBeInTheDocument(); expect(screen.getByText('Sample Question')).toBeInTheDocument();
expect(screen.getByText('Option A')).toBeInTheDocument(); expect(screen.getByText('Option A')).toBeInTheDocument();
expect(screen.getByText('Option B')).toBeInTheDocument(); expect(screen.getByText('Option B')).toBeInTheDocument();
expect(screen.getByText('Quitter')).toBeInTheDocument(); expect(screen.getByText('Quitter')).toBeInTheDocument();
@ -63,54 +52,10 @@ describe('TeacherModeQuiz', () => {
act(() => { act(() => {
fireEvent.click(screen.getByText('Répondre')); fireEvent.click(screen.getByText('Répondre'));
}); });
expect(mockSubmitAnswer).toHaveBeenCalledWith(['Option A'], 1); expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', 1);
mockSubmitAnswer.mockClear(); expect(screen.getByText('Votre réponse est "Option A".')).toBeInTheDocument();
}); });
test('handles shows feedback for an already answered question', () => {
// Answer the first question
act(() => {
fireEvent.click(screen.getByText('Option A'));
});
act(() => {
fireEvent.click(screen.getByText('Répondre'));
});
expect(mockSubmitAnswer).toHaveBeenCalledWith(['Option A'], 1);
mockSubmitAnswer.mockClear();
mockQuestion = mockQuestions[1].question as MultipleChoiceQuestion;
// Navigate to the next question by re-rendering with new props
act(() => {
rerender(
<MemoryRouter>
<TeacherModeQuiz
questionInfos={{ question: mockQuestion }}
answers={Array(mockQuestions.length).fill({} as AnswerSubmissionToBackendType)}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
</MemoryRouter>
);
});
mockQuestion = mockQuestions[0].question as MultipleChoiceQuestion;
act(() => {
rerender(
<MemoryRouter>
<TeacherModeQuiz
questionInfos={{ question: mockQuestion }}
answers={Array(mockQuestions.length).fill({} as AnswerSubmissionToBackendType)}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
</MemoryRouter>
);
});
// Check if the feedback dialog is shown again
expect(screen.getByText('Rétroaction')).toBeInTheDocument();
});
test('handles disconnect button click', () => { test('handles disconnect button click', () => {
act(() => { act(() => {
fireEvent.click(screen.getByText('Quitter')); fireEvent.click(screen.getByText('Quitter'));

View file

@ -1,95 +0,0 @@
import React from 'react';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes, useParams } from 'react-router-dom';
import Share from '../../../../pages/Teacher/Share/Share.tsx';
import ApiService from '../../../../services/ApiService';
import '@testing-library/jest-dom';
// Mock the ApiService and react-router-dom
jest.mock('../../../../services/ApiService');
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: jest.fn(),
useNavigate: jest.fn(),
}));
describe('Share Component', () => {
const mockNavigate = jest.fn();
const mockUseParams = useParams as jest.Mock;
const mockApiService = ApiService as jest.Mocked<typeof ApiService>;
beforeEach(() => {
jest.clearAllMocks();
mockUseParams.mockReturnValue({ id: 'quiz123' });
require('react-router-dom').useNavigate.mockReturnValue(mockNavigate);
});
const renderComponent = (initialEntries = ['/share/quiz123']) => {
return render(
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route path="/share/:id" element={<Share />} />
<Route path="/teacher/dashboard" element={<div>Dashboard</div>} />
<Route path="/login" element={<div>Login</div>} />
</Routes>
</MemoryRouter>
);
};
it('should show loading state initially', () => {
mockApiService.getAllQuizIds.mockResolvedValue([]);
mockApiService.getUserFolders.mockResolvedValue([]);
mockApiService.getSharedQuiz.mockResolvedValue('Test Quiz');
renderComponent();
expect(screen.getByText('Chargement...')).toBeInTheDocument();
});
it('should redirect to login if not authenticated', async () => {
mockApiService.isLoggedIn.mockReturnValue(false);
renderComponent();
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/login');
});
});
it('should show "quiz already exists" message when quiz exists', async () => {
mockApiService.isLoggedIn.mockReturnValue(true);
mockApiService.getAllQuizIds.mockResolvedValue(['quiz123']);
renderComponent();
await waitFor(() => {
expect(screen.getByText('Quiz déjà existant')).toBeInTheDocument();
expect(screen.getByText(/Le quiz que vous essayez d'importer existe déjà sur votre compte/i)).toBeInTheDocument();
expect(screen.getByText('Retour au tableau de bord')).toBeInTheDocument();
});
});
it('should navigate to dashboard when clicking return button in "quiz exists" view', async () => {
mockApiService.isLoggedIn.mockReturnValue(true);
mockApiService.getAllQuizIds.mockResolvedValue(['quiz123']);
renderComponent();
await waitFor(() => {
fireEvent.click(screen.getByText('Retour au tableau de bord'));
expect(mockNavigate).toHaveBeenCalledWith('/teacher/dashboard');
});
});
it('should show error when no folders exist', async () => {
mockApiService.isLoggedIn.mockReturnValue(true);
mockApiService.getAllQuizIds.mockResolvedValue([]);
mockApiService.getUserFolders.mockResolvedValue([]);
renderComponent();
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/teacher/dashboard');
});
});
});

View file

@ -1,42 +0,0 @@
import axios from 'axios';
import ApiService from '../../services/ApiService';
import { ENV_VARIABLES } from '../../constants';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('getSharedQuiz', () => {
it('should call the API to get a shared quiz and return the quiz data on success', async () => {
const quizId = '123';
const quizData = 'Quiz data';
const response = { status: 200, data: { data: quizData } };
mockedAxios.get.mockResolvedValue(response);
const result = await ApiService.getSharedQuiz(quizId);
expect(mockedAxios.get).toHaveBeenCalledWith(
`${ENV_VARIABLES.VITE_BACKEND_URL}/api/quiz/getShare/${quizId}`,
{ headers: expect.any(Object) }
);
expect(result).toBe(quizData);
});
it('should return an error message if the API call fails', async () => {
const quizId = '123';
const errorMessage = 'An unexpected error occurred.';
mockedAxios.get.mockRejectedValue({ response: { data: { error: errorMessage } } });
const result = await ApiService.getSharedQuiz(quizId);
expect(result).toBe(errorMessage);
});
it('should return a generic error message if an unexpected error occurs', async () => {
const quizId = '123';
mockedAxios.get.mockRejectedValue(new Error('Unexpected error'));
const result = await ApiService.getSharedQuiz(quizId);
expect(result).toBe('An unexpected error occurred.');
});
});

View file

@ -1,9 +1,7 @@
//WebsocketService.test.tsx //WebsocketService.test.tsx
import { BaseQuestion, parse } from 'gift-pegjs';
import WebsocketService from '../../services/WebsocketService'; import WebsocketService from '../../services/WebsocketService';
import { io, Socket } from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
import { ENV_VARIABLES } from 'src/constants'; import { ENV_VARIABLES } from 'src/constants';
import { QuestionType } from 'src/Types/QuestionType';
jest.mock('socket.io-client'); jest.mock('socket.io-client');
@ -25,13 +23,13 @@ describe('WebSocketService', () => {
}); });
test('connect should initialize socket connection', () => { test('connect should initialize socket connection', () => {
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
expect(io).toHaveBeenCalled(); expect(io).toHaveBeenCalled();
expect(WebsocketService['socket']).toBe(mockSocket); expect(WebsocketService['socket']).toBe(mockSocket);
}); });
test('disconnect should terminate socket connection', () => { test('disconnect should terminate socket connection', () => {
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
expect(WebsocketService['socket']).toBeTruthy(); expect(WebsocketService['socket']).toBeTruthy();
WebsocketService.disconnect(); WebsocketService.disconnect();
expect(mockSocket.disconnect).toHaveBeenCalled(); expect(mockSocket.disconnect).toHaveBeenCalled();
@ -39,24 +37,17 @@ describe('WebSocketService', () => {
}); });
test('createRoom should emit create-room event', () => { test('createRoom should emit create-room event', () => {
const roomName = 'Test Room'; WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); WebsocketService.createRoom();
WebsocketService.createRoom(roomName); expect(mockSocket.emit).toHaveBeenCalledWith('create-room');
expect(mockSocket.emit).toHaveBeenCalledWith('create-room', roomName);
}); });
test('nextQuestion should emit next-question event with correct parameters', () => { test('nextQuestion should emit next-question event with correct parameters', () => {
const roomName = 'testRoom'; const roomName = 'testRoom';
const mockGiftQuestions = parse('A {T}'); const question = { id: 1, text: 'Sample Question' };
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
if (question.type !== "Category") mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
question.id = (index + 1).toString(); WebsocketService.nextQuestion(roomName, question);
const newMockQuestion = question;
return {question : newMockQuestion as BaseQuestion};
});
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.nextQuestion({roomName, questions: mockQuestions, questionIndex: 0, isLaunch: false});
const question = mockQuestions[0];
expect(mockSocket.emit).toHaveBeenCalledWith('next-question', { roomName, question }); expect(mockSocket.emit).toHaveBeenCalledWith('next-question', { roomName, question });
}); });
@ -64,7 +55,7 @@ describe('WebSocketService', () => {
const roomName = 'testRoom'; const roomName = 'testRoom';
const questions = [{ id: 1, text: 'Sample Question' }]; const questions = [{ id: 1, text: 'Sample Question' }];
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
WebsocketService.launchStudentModeQuiz(roomName, questions); WebsocketService.launchStudentModeQuiz(roomName, questions);
expect(mockSocket.emit).toHaveBeenCalledWith('launch-student-mode', { expect(mockSocket.emit).toHaveBeenCalledWith('launch-student-mode', {
roomName, roomName,
@ -75,7 +66,7 @@ describe('WebSocketService', () => {
test('endQuiz should emit end-quiz event with correct parameters', () => { test('endQuiz should emit end-quiz event with correct parameters', () => {
const roomName = 'testRoom'; const roomName = 'testRoom';
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
WebsocketService.endQuiz(roomName); WebsocketService.endQuiz(roomName);
expect(mockSocket.emit).toHaveBeenCalledWith('end-quiz', { roomName }); expect(mockSocket.emit).toHaveBeenCalledWith('end-quiz', { roomName });
}); });
@ -84,7 +75,7 @@ describe('WebSocketService', () => {
const enteredRoomName = 'testRoom'; const enteredRoomName = 'testRoom';
const username = 'testUser'; const username = 'testUser';
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
WebsocketService.joinRoom(enteredRoomName, username); WebsocketService.joinRoom(enteredRoomName, username);
expect(mockSocket.emit).toHaveBeenCalledWith('join-room', { enteredRoomName, username }); expect(mockSocket.emit).toHaveBeenCalledWith('join-room', { enteredRoomName, username });
}); });

View file

@ -1,42 +0,0 @@
import axios from 'axios';
import ApiService from '../../services/ApiService';
import { FolderType } from '../../Types/FolderType';
import { QuizType } from '../../Types/QuizType';
jest.mock('axios');
const mockedAxios = axios as jest.Mocked<typeof axios>;
describe('ApiService', () => {
describe('getAllQuizIds', () => {
it('should return all quiz IDs from all folders', async () => {
const folders: FolderType[] = [
{ _id: 'folder1', title: 'Folder 1', userId: 'user1', created_at: new Date().toISOString() },
{ _id: 'folder2', title: 'Folder 2', userId: 'user2', created_at: new Date().toISOString() }
];
const quizzesFolder1: QuizType[] = [
{ _id: 'quiz1', title: 'Quiz 1', content: [], folderId: 'folder1', folderName: 'Folder 1', userId: 'user1', created_at: new Date(), updated_at: new Date() },
{ _id: 'quiz2', title: 'Quiz 2', content: [], folderId: 'folder1', folderName: 'Folder 1', userId: 'user1', created_at: new Date(), updated_at: new Date() }
];
const quizzesFolder2: QuizType[] = [
{ _id: 'quiz3', title: 'Quiz 3', content: [], folderId: 'folder2', folderName: 'Folder 2', userId: 'user2', created_at: new Date(), updated_at: new Date() }
];
mockedAxios.get
.mockResolvedValueOnce({ status: 200, data: { data: folders } })
.mockResolvedValueOnce({ status: 200, data: { data: quizzesFolder1 } })
.mockResolvedValueOnce({ status: 200, data: { data: quizzesFolder2 } });
const result = await ApiService.getAllQuizIds();
expect(result).toEqual(['quiz1', 'quiz2', 'quiz3']);
});
it('should return an empty array if no folders are found', async () => {
mockedAxios.get.mockResolvedValueOnce({ status: 200, data: { data: [] } });
const result = await ApiService.getAllQuizIds();
expect(result).toEqual([]);
});
});
});

View file

@ -21,11 +21,11 @@ const GiftCheatSheet: React.FC = () => {
}; };
const QuestionVraiFaux = "::Exemple de question vrai/faux:: \n 2+2 \\= 4 ? {T} //Utilisez les valeurs {T}, {F}, {TRUE} et {FALSE}."; const QuestionVraiFaux = "2+2 \\= 4 ? {T}\n// Utilisez les valeurs {T}, {F}, {TRUE} \net {FALSE}.";
const QuestionChoixMul = "::Ville capitale du Canada:: \nQuelle ville est la capitale du Canada? {\n~ Toronto\n~ Montréal\n= Ottawa #Rétroaction spécifique.\n} // Commentaire non visible (au besoin)"; const QuestionChoixMul = "Quelle ville est la capitale du Canada? {\n~ Toronto\n~ Montréal\n= Ottawa #Bonne réponse!\n}\n// La bonne réponse est Ottawa";
const QuestionChoixMulMany = "::Villes canadiennes:: \n Quelles villes trouve-t-on au Canada? { \n~ %33.3% Montréal \n ~ %33.3% Ottawa \n ~ %33.3% Vancouver \n ~ %-100% New York \n ~ %-100% Paris \n#### Rétroaction globale de la question. \n} // Utilisez tilde (signe de vague) pour toutes les réponses. // On doit indiquer le pourcentage de chaque réponse."; const QuestionChoixMulMany = "Quelles villes trouve-t-on au Canada? { \n~ %33.3% Montréal \n ~ %33.3% Ottawa \n ~ %33.3% Vancouver \n ~ %-100% New York \n ~ %-100% Paris \n#### La bonne réponse est Montréal, Ottawa et Vancouver \n}\n// Utilisez tilde (signe de vague) pour toutes les réponses.\n// On doit indiquer le pourcentage de chaque réponse.";
const QuestionCourte ="::Clé et porte:: \n Avec quoi ouvre-t-on une porte? { \n= clé \n= clef \n} // Permet de fournir plusieurs bonnes réponses. // Note: La casse n'est pas prise en compte."; const QuestionCourte ="Avec quoi ouvre-t-on une porte? { \n= clé \n= clef \n}\n// Permet de fournir plusieurs bonnes réponses.\n// Note: La casse n'est pas prise en compte.";
const QuestionNum ="::Question numérique avec marge:: \nQuel est un nombre de 1 à 5 ? {\n#3:2\n}\n \n// Plage mathématique spécifiée avec des points de fin d'intervalle. \n ::Question numérique avec plage:: \n Quel est un nombre de 1 à 5 ? {\n#1..5\n} \n\n// Réponses numériques multiples avec crédit partiel et commentaires.\n::Question numérique avec plusieurs réponses::\nQuand est né Ulysses S. Grant ? {\n# =1822:0 # Correct ! Crédit complet. \n=%50%1822:2 # Il est né en 1822. Demi-crédit pour être proche.\n}"; const QuestionNum ="// Question de plage mathématique. \n Quel est un nombre de 1 à 5 ? {\n#3:2\n}\n \n// Plage mathématique spécifiée avec des points de fin d'intervalle. \n Quel est un nombre de 1 à 5 ? {\n#1..5\n} \n\n// Réponses numériques multiples avec crédit partiel et commentaires.\nQuand est né Ulysses S. Grant ? {\n# =1822:0 # Correct ! Crédit complet. \n=%50%1822:2 # Il est né en 1822. Demi-crédit pour être proche.\n}";
return ( return (
<div className="gift-cheat-sheet"> <div className="gift-cheat-sheet">
<h2 className="subtitle">Informations pratiques sur l&apos;éditeur</h2> <h2 className="subtitle">Informations pratiques sur l&apos;éditeur</h2>
@ -79,7 +79,7 @@ const GiftCheatSheet: React.FC = () => {
</div> </div>
<div className="question-type"> <div className="question-type">
<h4> 5. Questions numériques </h4> <h4> 5. Question numérique </h4>
<pre> <pre>
<code className="question-code-block selectable-text"> <code className="question-code-block selectable-text">
{ {

View file

@ -1,9 +1,9 @@
// GIFTTemplatePreview.tsx // GIFTTemplatePreview.tsx
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import Template, { ErrorTemplate, UnsupportedQuestionTypeError } from './templates'; import Template, { ErrorTemplate } from './templates';
import { parse } from 'gift-pegjs'; import { parse } from 'gift-pegjs';
import './styles.css'; import './styles.css';
import { FormattedTextTemplate } from './templates/TextTypeTemplate'; import DOMPurify from 'dompurify';
interface GIFTTemplatePreviewProps { interface GIFTTemplatePreviewProps {
questions: string[]; questions: string[];
@ -22,6 +22,19 @@ const GIFTTemplatePreview: React.FC<GIFTTemplatePreviewProps> = ({
try { try {
let previewHTML = ''; let previewHTML = '';
questions.forEach((giftQuestion) => { questions.forEach((giftQuestion) => {
// TODO : afficher un message que les images spécifiées par <img> sont dépréciées et qu'il faut utiliser [markdown] et la syntaxe ![alt](url)
// const isImage = item.includes('<img');
// if (isImage) {
// const imageUrlMatch = item.match(/<img[^>]+>/i);
// if (imageUrlMatch) {
// let imageUrl = imageUrlMatch[0];
// imageUrl = imageUrl.replace('img', 'img style="width:10vw;" src=');
// item = item.replace(imageUrlMatch[0], '');
// previewHTML += `${imageUrl}`;
// }
// }
try { try {
const question = parse(giftQuestion); const question = parse(giftQuestion);
previewHTML += Template(question[0], { previewHTML += Template(question[0], {
@ -29,15 +42,11 @@ const GIFTTemplatePreview: React.FC<GIFTTemplatePreviewProps> = ({
theme: 'light' theme: 'light'
}); });
} catch (error) { } catch (error) {
let errorMsg: string; if (error instanceof Error) {
if (error instanceof UnsupportedQuestionTypeError) { previewHTML += ErrorTemplate(giftQuestion + '\n' + error.message);
errorMsg = ErrorTemplate(giftQuestion, `Erreur: ${error.message}`);
} else if (error instanceof Error) {
errorMsg = ErrorTemplate(giftQuestion, `Erreur GIFT: ${error.message}`);
} else { } else {
errorMsg = ErrorTemplate(giftQuestion, 'Erreur inconnue'); previewHTML += ErrorTemplate(giftQuestion + '\n' + 'Erreur inconnue');
} }
previewHTML += `<div label="error-message">${errorMsg}</div>`;
} }
}); });
@ -65,8 +74,7 @@ const GIFTTemplatePreview: React.FC<GIFTTemplatePreviewProps> = ({
<div className="error">{error}</div> <div className="error">{error}</div>
) : isPreviewReady ? ( ) : isPreviewReady ? (
<div data-testid="preview-container"> <div data-testid="preview-container">
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(items) }}></div>
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate({ format: 'html', text: items }) }}></div>
</div> </div>
) : ( ) : (
<div className="loading">Chargement de la prévisualisation...</div> <div className="loading">Chargement de la prévisualisation...</div>

View file

@ -18,11 +18,11 @@ What is the capital of Canada? {=Canada -> Ottawa =Italy -> Rome =Japan -> Tokyo
const items = multiple.map((item) => Template(item, { theme: 'dark' })).join(''); const items = multiple.map((item) => Template(item, { theme: 'dark' })).join('');
const errorItemDark = ErrorTemplate('Hello', 'Error'); const errorItemDark = ErrorTemplate('Hello');
const lightItems = multiple.map((item) => Template(item, { theme: 'light' })).join(''); const lightItems = multiple.map((item) => Template(item, { theme: 'light' })).join('');
const errorItem = ErrorTemplate('Hello', 'Error'); const errorItem = ErrorTemplate('Hello');
const app = document.getElementById('app'); const app = document.getElementById('app');
if (app) app.innerHTML = items + errorItemDark + lightItems + errorItem; if (app) app.innerHTML = items + errorItemDark + lightItems + errorItem;

View file

@ -25,11 +25,11 @@ export default function AnswerIcon({ correct }: AnswerIconOptions): string {
`; `;
const CorrectIcon = (): string => { const CorrectIcon = (): string => {
return `<svg data-testid="correct-icon" style="${Icon} ${Correct}" role="img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"></path></svg>`; return `<svg style="${Icon} ${Correct}" role="img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path fill="currentColor" d="M173.898 439.404l-166.4-166.4c-9.997-9.997-9.997-26.206 0-36.204l36.203-36.204c9.997-9.998 26.207-9.998 36.204 0L192 312.69 432.095 72.596c9.997-9.997 26.207-9.997 36.204 0l36.203 36.204c9.997 9.997 9.997 26.206 0 36.204l-294.4 294.401c-9.998 9.997-26.207 9.997-36.204-.001z"></path></svg>`;
}; };
const IncorrectIcon = (): string => { const IncorrectIcon = (): string => {
return `<svg data-testid="incorrect-icon" style="${Icon} ${Incorrect}" role="img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="currentColor" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>`; return `<svg style="${Icon} ${Incorrect}" role="img" aria-hidden="true" focusable="false" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 352 512"><path fill="currentColor" d="M242.72 256l100.07-100.07c12.28-12.28 12.28-32.19 0-44.48l-22.24-22.24c-12.28-12.28-32.19-12.28-44.48 0L176 189.28 75.93 89.21c-12.28-12.28-32.19-12.28-44.48 0L9.21 111.45c-12.28 12.28-12.28 32.19 0 44.48L109.28 256 9.21 356.07c-12.28 12.28-12.28 32.19 0 44.48l22.24 22.24c12.28 12.28 32.2 12.28 44.48 0L176 322.72l100.07 100.07c12.28 12.28 32.2 12.28 44.48 0l22.24-22.24c12.28-12.28 12.28-32.19 0-44.48L242.72 256z"></path></svg>`;
}; };
return correct ? CorrectIcon() : IncorrectIcon(); return correct ? CorrectIcon() : IncorrectIcon();

View file

@ -1,7 +1,7 @@
import { theme, ParagraphStyle } from '../constants'; import { theme, ParagraphStyle } from '../constants';
import { state } from '.'; import { state } from '.';
export default function (questionText: string, errorText: string): string { export default function (text: string): string {
const Container = ` const Container = `
flex-wrap: wrap; flex-wrap: wrap;
position: relative; position: relative;
@ -13,49 +13,47 @@ export default function (questionText: string, errorText: string): string {
box-shadow: 0px 1px 3px ${theme(state.theme, 'gray400', 'black900')}; box-shadow: 0px 1px 3px ${theme(state.theme, 'gray400', 'black900')};
`; `;
// const document = removeBackslash(lineRegex(documentRegex(text))).split(/\r?\n/); const document = removeBackslash(lineRegex(documentRegex(text))).split(/\r?\n/);
// return document[0] !== `` return document[0] !== ``
// ? `<section style="${Container}">${document ? `<section style="${Container}">${document
// .map((i) => `<p style="${ParagraphStyle(state.theme)}">${i}</p>`) .map((i) => `<p style="${ParagraphStyle(state.theme)}">${i}</p>`)
// .join('')}</section>` .join('')}</section>`
// : ``; : ``;
return `<section style="${Container}"><p style="${ParagraphStyle(state.theme)}">${questionText}<br><em>${errorText}</em></p></section>`;
} }
// function documentRegex(text: string): string { function documentRegex(text: string): string {
// const newText = text const newText = text
// .split(/\r?\n/) .split(/\r?\n/)
// .map((comment) => comment.replace(/(^[ \\t]+)?(^)((\/\/))(.*)/gm, '')) .map((comment) => comment.replace(/(^[ \\t]+)?(^)((\/\/))(.*)/gm, ''))
// .join(''); .join('');
// const newLineAnswer = /([^\\]|[^\S\r\n][^=])(=|~)/g; const newLineAnswer = /([^\\]|[^\S\r\n][^=])(=|~)/g;
// const correctAnswer = /([^\\]|^{)(([^\\]|^|\\s*)=(.*)(?=[=~}]|\\n))/g; const correctAnswer = /([^\\]|^{)(([^\\]|^|\\s*)=(.*)(?=[=~}]|\\n))/g;
// const incorrectAnswer = /([^\\]|^{)(([^\\]|^|\\s*)~(.*)(?=[=~}]|\\n))/g; const incorrectAnswer = /([^\\]|^{)(([^\\]|^|\\s*)~(.*)(?=[=~}]|\\n))/g;
// return newText return newText
// .replace(newLineAnswer, `\n$2`) .replace(newLineAnswer, `\n$2`)
// .replace(correctAnswer, `$1<li>$4</li>`) .replace(correctAnswer, `$1<li>$4</li>`)
// .replace(incorrectAnswer, `$1<li>$4</li>`); .replace(incorrectAnswer, `$1<li>$4</li>`);
// } }
// function lineRegex(text: string): string { function lineRegex(text: string): string {
// return text return text
// // CPF: disabled the following regex because it's not clear what it's supposed to do .split(/\r?\n/)
// // .split(/\r?\n/) .map((category) =>
// // .map((category) => category.replace(/(^[ \\t]+)?(((^|\n)\s*[$]CATEGORY:))(.+)/g, `<br><b>$5</b><br>`)
// // category.replace(/(^[ \\t]+)?(((^|\n)\s*[$]CATEGORY:))(.+)/g, `<br><b>$5</b><br>`) )
// // ) .map((title) => title.replace(/\s*(::)\s*(.*?)(::)/g, `<br><b>$2</b><br>`))
// // .map((title) => title.replace(/\s*(::)\s*(.*?)(::)/g, `<br><b>$2</b><br>`)) .map((openBracket) => openBracket.replace(/([^\\]|^){([#])?/g, `$1<br>`))
// // .map((openBracket) => openBracket.replace(/([^\\]|^){([#])?/g, `$1<br>`)) .map((closeBracket) => closeBracket.replace(/([^\\]|^)}/g, `$1<br>`))
// // .map((closeBracket) => closeBracket.replace(/([^\\]|^)}/g, `$1<br>`)) .join('');
// // .join(''); }
// }
// function removeBackslash(text: string): string { function removeBackslash(text: string): string {
// return text return text
// .split(/\r?\n/) .split(/\r?\n/)
// .map((colon) => colon.replace(/[\\]:/g, ':')) .map((colon) => colon.replace(/[\\]:/g, ':'))
// .map((openBracket) => openBracket.replace(/[\\]{/g, '{')) .map((openBracket) => openBracket.replace(/[\\]{/g, '{'))
// .map((closeBracket) => closeBracket.replace(/[\\]}/g, '}')) .map((closeBracket) => closeBracket.replace(/[\\]}/g, '}'))
// .join(''); .join('');
// } }

View file

@ -13,14 +13,14 @@ type AnswerFeedbackOptions = TemplateOptions & Pick<TextChoice, 'formattedFeedba
interface AnswerWeightOptions extends TemplateOptions { interface AnswerWeightOptions extends TemplateOptions {
weight: TextChoice['weight']; weight: TextChoice['weight'];
} }
// careful -- this template is re-used by True/False questions!
export default function MultipleChoiceAnswersTemplate({ choices }: MultipleChoiceAnswerOptions) { export default function MultipleChoiceAnswersTemplate({ choices }: MultipleChoiceAnswerOptions) {
const id = `id${nanoid(8)}`; const id = `id${nanoid(8)}`;
const hasManyCorrectChoices = choices.filter(({ isCorrect }) => isCorrect === true).length > 1; const isMultipleAnswer = choices.filter(({ isCorrect }) => isCorrect === true).length === 0;
const prompt = `<span style="${ParagraphStyle(state.theme)}">Choisir une réponse${ const prompt = `<span style="${ParagraphStyle(state.theme)}">Choisir une réponse${
hasManyCorrectChoices ? ` ou plusieurs` : `` isMultipleAnswer ? ` ou plusieurs` : ``
}:</span>`; }:</span>`;
const result = choices const result = choices
.map(({ weight, isCorrect, formattedText, formattedFeedback }) => { .map(({ weight, isCorrect, formattedText, formattedFeedback }) => {
@ -32,12 +32,12 @@ export default function MultipleChoiceAnswersTemplate({ choices }: MultipleChoic
const inputId = `id${nanoid(6)}`; const inputId = `id${nanoid(6)}`;
const isPositiveWeight = (weight != undefined) && (weight > 0); const isPositiveWeight = (weight != undefined) && (weight > 0);
const isCorrectOption = hasManyCorrectChoices ? isPositiveWeight || isCorrect : isCorrect; const isCorrectOption = isMultipleAnswer ? isPositiveWeight : isCorrect;
return ` return `
<div class='multiple-choice-answers-container'> <div class='multiple-choice-answers-container'>
<input class="gift-input" type="${ <input class="gift-input" type="${
hasManyCorrectChoices ? 'checkbox' : 'radio' isMultipleAnswer ? 'checkbox' : 'radio'
}" id="${inputId}" name="${id}"> }" id="${inputId}" name="${id}">
${AnswerWeight({ weight: weight })} ${AnswerWeight({ weight: weight })}
<label style="${CustomLabel} ${ParagraphStyle(state.theme)}" for="${inputId}"> <label style="${CustomLabel} ${ParagraphStyle(state.theme)}" for="${inputId}">

View file

@ -4,24 +4,14 @@ import katex from 'katex';
import { TextFormat } from 'gift-pegjs'; import { TextFormat } from 'gift-pegjs';
import DOMPurify from 'dompurify'; // cleans HTML to prevent XSS attacks, etc. import DOMPurify from 'dompurify'; // cleans HTML to prevent XSS attacks, etc.
function formatLatex(text: string): string { export function formatLatex(text: string): string {
return text
let renderedText = '';
try {
renderedText = text
.replace(/\$\$(.*?)\$\$/g, (_, inner) => katex.renderToString(inner, { displayMode: true })) .replace(/\$\$(.*?)\$\$/g, (_, inner) => katex.renderToString(inner, { displayMode: true }))
.replace(/\$(.*?)\$/g, (_, inner) => katex.renderToString(inner, { displayMode: false })) .replace(/\$(.*?)\$/g, (_, inner) => katex.renderToString(inner, { displayMode: false }))
.replace(/\\\[(.*?)\\\]/g, (_, inner) => katex.renderToString(inner, { displayMode: true })) .replace(/\\\[(.*?)\\\]/g, (_, inner) => katex.renderToString(inner, { displayMode: true }))
.replace(/\\\((.*?)\\\)/g, (_, inner) => .replace(/\\\((.*?)\\\)/g, (_, inner) =>
katex.renderToString(inner, { displayMode: false }) katex.renderToString(inner, { displayMode: false })
); );
} catch (error) {
console.log('Error rendering LaTeX (KaTeX):', error);
renderedText = text;
}
return renderedText;
} }
/** /**

View file

@ -6,32 +6,20 @@ import {
MultipleChoiceQuestion as MultipleChoiceType, MultipleChoiceQuestion as MultipleChoiceType,
NumericalQuestion as NumericalType, NumericalQuestion as NumericalType,
ShortAnswerQuestion as ShortAnswerType, ShortAnswerQuestion as ShortAnswerType,
// EssayQuestion as EssayType, // Essay as EssayType,
TrueFalseQuestion as TrueFalseType, TrueFalseQuestion as TrueFalseType,
// MatchingQuestion as MatchingType, // MatchingQuestion as MatchingType,
} from 'gift-pegjs'; } from 'gift-pegjs';
import { DisplayOptions } from './types'; import { DisplayOptions } from './types';
// import DescriptionTemplate from './DescriptionTemplate'; import DescriptionTemplate from './DescriptionTemplate';
// import EssayTemplate from './EssayTemplate'; import EssayTemplate from './EssayTemplate';
// import MatchingTemplate from './MatchingTemplate'; import MatchingTemplate from './MatchingTemplate';
import MultipleChoiceTemplate from './MultipleChoiceTemplate'; import MultipleChoiceTemplate from './MultipleChoiceTemplate';
import NumericalTemplate from './NumericalTemplate'; import NumericalTemplate from './NumericalTemplate';
import ShortAnswerTemplate from './ShortAnswerTemplate'; import ShortAnswerTemplate from './ShortAnswerTemplate';
import TrueFalseTemplate from './TrueFalseTemplate'; import TrueFalseTemplate from './TrueFalseTemplate';
import Error from './ErrorTemplate'; import Error from './ErrorTemplate';
// import CategoryTemplate from './CategoryTemplate'; import CategoryTemplate from './CategoryTemplate';
export class UnsupportedQuestionTypeError extends globalThis.Error {
constructor(type: string) {
const userFriendlyType = (type === 'Essay') ? 'Réponse longue (Essay)'
: (type === 'Matching') ? 'Association (Matching)'
: (type === 'Category') ? 'Catégorie (Category)'
: type;
super(`Les questions du type ${userFriendlyType} ne sont pas supportées.`);
this.name = 'UnsupportedQuestionTypeError';
}
}
export const state: DisplayOptions = { preview: true, theme: 'light' }; export const state: DisplayOptions = { preview: true, theme: 'light' };
@ -66,21 +54,23 @@ export default function Template(
// case 'Matching': // case 'Matching':
// return Matching({ ...(keys as MatchingType) }); // return Matching({ ...(keys as MatchingType) });
default: default:
// convert type to human-readable string // TODO: throw error for unsupported question types?
throw new UnsupportedQuestionTypeError(type); } // throw new Error(`Unsupported question type: ${type}`);
return ``;
}
} }
export function ErrorTemplate(questionText: string, errorText: string, options?: Partial<DisplayOptions>): string { export function ErrorTemplate(text: string, options?: Partial<DisplayOptions>): string {
Object.assign(state, options); Object.assign(state, options);
return Error(questionText, errorText); return Error(text);
} }
export { export {
// CategoryTemplate, CategoryTemplate,
// DescriptionTemplate as Description, DescriptionTemplate as Description,
// EssayTemplate as Essay, EssayTemplate as Essay,
// MatchingTemplate as Matching, MatchingTemplate as Matching,
MultipleChoiceTemplate as MultipleChoice, MultipleChoiceTemplate as MultipleChoice,
NumericalTemplate as Numerical, NumericalTemplate as Numerical,
ShortAnswerTemplate as ShortAnswer, ShortAnswerTemplate as ShortAnswer,

View file

@ -1,11 +1,10 @@
import { Link, useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import * as React from 'react'; import * as React from 'react';
import './header.css'; import './header.css';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import ExitToAppIcon from '@mui/icons-material/ExitToApp';
interface HeaderProps { interface HeaderProps {
isLoggedIn: boolean; isLoggedIn: () => boolean;
handleLogout: () => void; handleLogout: () => void;
} }
@ -21,7 +20,7 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, handleLogout }) => {
onClick={() => navigate('/')} onClick={() => navigate('/')}
/> />
{isLoggedIn && ( {isLoggedIn() && (
<Button <Button
variant="outlined" variant="outlined"
color="primary" color="primary"
@ -29,19 +28,10 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, handleLogout }) => {
handleLogout(); handleLogout();
navigate('/'); navigate('/');
}} }}
startIcon={<ExitToAppIcon />}
> >
Déconnexion Logout
</Button> </Button>
)} )}
{!isLoggedIn && (
<div className="auth-selection-btn">
<Link to="/login">
<button className="auth-btn">Connexion</button>
</Link>
</div>
)}
</div> </div>
); );
}; };

View file

@ -1,309 +0,0 @@
import React, { useState, useEffect } from "react";
import {
Box,
CircularProgress,
Button,
IconButton,
Card,
CardContent,
Dialog,
DialogContent,
DialogActions,
DialogTitle,
DialogContentText,
Tabs,
Tab,
TextField, Snackbar,
Alert
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import CloseIcon from "@mui/icons-material/Close";
import { ImageType } from "../../Types/ImageType";
import ApiService from "../../services/ApiService";
import { Upload } from "@mui/icons-material";
import { escapeForGIFT } from "src/utils/giftUtils";
import { ENV_VARIABLES } from "src/constants";
interface ImagesProps {
handleCopy?: (id: string) => void;
handleDelete?: (id: string) => void;
}
const ImageGallery: React.FC<ImagesProps> = ({ handleCopy, handleDelete }) => {
const [images, setImages] = useState<ImageType[]>([]);
const [totalImg, setTotalImg] = useState(0);
const [imgPage, setImgPage] = useState(1);
const [imgLimit] = useState(6);
const [loading, setLoading] = useState(false);
const [selectedImage, setSelectedImage] = useState<ImageType | null>(null);
const [openDeleteDialog, setOpenDeleteDialog] = useState(false);
const [imageToDelete, setImageToDelete] = useState<ImageType | null>(null);
const [tabValue, setTabValue] = useState(0);
const [importedImage, setImportedImage] = useState<File | null>(null);
const [preview, setPreview] = useState<string | null>(null);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [snackbarSeverity, setSnackbarSeverity] = useState<"success" | "error">("success");
const fetchImages = async () => {
setLoading(true);
const data = await ApiService.getUserImages(imgPage, imgLimit);
setImages(data.images);
setTotalImg(data.total);
setLoading(false);
};
useEffect(() => {
fetchImages();
}, [imgPage]);
const defaultHandleDelete = async (id: string) => {
if (imageToDelete) {
setLoading(true);
const isDeleted = await ApiService.deleteImage(id);
setLoading(false);
if (isDeleted) {
setImgPage(1);
fetchImages();
setSnackbarMessage("Image supprimée avec succès!");
setSnackbarSeverity("success");
} else {
setSnackbarMessage("Erreur lors de la suppression de l'image. Veuillez réessayer.");
setSnackbarSeverity("error");
}
setSnackbarOpen(true);
setSelectedImage(null);
setImageToDelete(null);
setOpenDeleteDialog(false);
}
};
const defaultHandleCopy = (id: string) => {
if (navigator.clipboard) {
const link = `${ENV_VARIABLES.BACKEND_URL}/api/image/get/${id}`;
const imgTag = `[markdown] ![texte alternatif d'écrivant l'image pour les personnes qui ne peuvent pas voir l'image](${escapeForGIFT(link)} "texte de l'infobulle (ne fonctionne pas sur écran tactile généralement)") `;
setSnackbarMessage("Le lien Markdown de l'image a été copié dans le presse-papiers");
setSnackbarSeverity("success");
setSnackbarOpen(true);
navigator.clipboard.writeText(imgTag);
}
if(handleCopy) {
handleCopy(id);
}
};
const handleDeleteFunction = handleDelete || defaultHandleDelete;
const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files ? event.target.files[0] : null;
setImportedImage(file);
if (file) {
const objectUrl = URL.createObjectURL(file);
setPreview(objectUrl);
}
};
const handleSaveImage = async () => {
try {
if (!importedImage) {
setSnackbarMessage("Veuillez choisir une image à téléverser.");
setSnackbarSeverity("error");
setSnackbarOpen(true);
return;
}
const imageUrl = await ApiService.uploadImage(importedImage);
if (imageUrl.includes("ERROR")) {
setSnackbarMessage("Une erreur est survenue. Veuillez réessayer plus tard.");
setSnackbarSeverity("error");
setSnackbarOpen(true);
return;
}
fetchImages();
setSnackbarMessage("Téléversée avec succès !");
setSnackbarSeverity("success");
setSnackbarOpen(true);
setImportedImage(null);
setPreview(null);
setTabValue(0);
} catch (error) {
setSnackbarMessage(`Une erreur est survenue.\n${error}\nVeuillez réessayer plus tard.`);
setSnackbarSeverity("error");
setSnackbarOpen(true);
}
};
const handleCloseSnackbar = () => {
setSnackbarOpen(false);
};
return (
<Box p={3}>
<Tabs value={tabValue} onChange={(_, newValue) => setTabValue(newValue)}>
<Tab label="Galerie" />
<Tab label="Import" />
</Tabs>
{tabValue === 0 && (
<>
{loading ? (
<Box display="flex" justifyContent="center" alignItems="center" height={200}>
<CircularProgress />
</Box>
) : (
<>
<Box display="grid" gridTemplateColumns="repeat(3, 1fr)" gridTemplateRows="repeat(2, 1fr)" gap={2} maxWidth="900px" margin="auto">
{images.map((obj) => (
<Card key={obj.id} sx={{ cursor: "pointer", display: "flex", flexDirection: "column", alignItems: "center" }} onClick={() => setSelectedImage(obj)}>
<CardContent sx={{ p: 0 }}>
<img
src={`data:${obj.mime_type};base64,${obj.file_content}`}
alt={`Image ${obj.file_name}`}
style={{ width: "100%", height: 250, objectFit: "cover", borderRadius: 8 }}
/>
</CardContent>
<Box display="flex" justifyContent="center" mt={1}>
<IconButton onClick={(e) => {
e.stopPropagation();
defaultHandleCopy(obj.id);
}}
color="primary"
data-testid={`gallery-tab-copy-${obj.id}`} >
<ContentCopyIcon sx={{ fontSize: 18 }} />
</IconButton>
<IconButton
onClick={(e) => {
e.stopPropagation();
setImageToDelete(obj);
setOpenDeleteDialog(true);
}}
color="error"
data-testid={`gallery-tab-delete-${obj.id}`} >
<DeleteIcon sx={{ fontSize: 18 }} />
</IconButton>
</Box>
</Card>
))}
</Box>
<Box display="flex" justifyContent="center" mt={2}>
<Button onClick={() => setImgPage((prev) => Math.max(prev - 1, 1))} disabled={imgPage === 1} color="primary">
Précédent
</Button>
<Button onClick={() => setImgPage((prev) => (prev * imgLimit < totalImg ? prev + 1 : prev))} disabled={imgPage * imgLimit >= totalImg} color="primary">
Suivant
</Button>
</Box>
</>
)}
</>
)}
{tabValue === 1 && (
<Box display="flex" flexDirection="column" alignItems="center" width="100%" mt={3}>
{/* Image Preview at the top */}
{preview && (
<Box
mt={2}
mb={2}
sx={{
width: "100%",
maxWidth: 600,
textAlign: "center",
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
>
<img
src={preview}
alt="Preview"
style={{
width: "100%",
height: "auto",
borderRadius: 8,
maxHeight: "600px",
}}
/>
</Box>
)}
<Box display="flex" flexDirection="row" alignItems="center" width="100%" maxWidth={400}>
<TextField
type="file"
data-testid="file-input"
onChange={handleImageUpload}
slotProps={{
htmlInput: {
"data-testid": "file-input",
accept: "image/*",
},
}}
sx={{ flexGrow: 1 }}
/>
<Button
variant="outlined"
aria-label="Téléverser"
onClick={() => { handleSaveImage() }}
sx={{ ml: 2, height: "100%" }}
>
Téléverser <Upload sx={{ ml: 1 }} />
</Button>
</Box>
</Box>
)}
<Dialog open={!!selectedImage} onClose={() => setSelectedImage(null)} maxWidth="md">
<IconButton color="primary" onClick={() => setSelectedImage(null)} sx={{ position: "absolute", right: 8, top: 8, zIndex: 1 }}
data-testid="close-button">
<CloseIcon />
</IconButton>
<DialogContent>
{selectedImage && (
<img
src={`data:${selectedImage.mime_type};base64,${selectedImage.file_content}`}
alt="Enlarged view"
style={{ width: "100%", height: "auto", borderRadius: 8, maxHeight: "500px" }}
/>
)}
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={openDeleteDialog} onClose={() => setOpenDeleteDialog(false)}>
<DialogTitle>Supprimer</DialogTitle>
<DialogContent>
<DialogContentText>Voulez-vous supprimer cette image?</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenDeleteDialog(false)} color="primary">
Annuler
</Button>
<Button onClick={() => imageToDelete && handleDeleteFunction(imageToDelete.id)} color="error">
Delete
</Button>
</DialogActions>
</Dialog>
<Snackbar
open={snackbarOpen}
autoHideDuration={4000}
onClose={handleCloseSnackbar}
>
<Alert
onClose={handleCloseSnackbar}
severity={snackbarSeverity}
sx={{ width: "100%" }}>
{snackbarMessage}
</Alert>
</Snackbar>
</Box>
);
};
export default ImageGallery;

View file

@ -1,55 +0,0 @@
import React, { useState } from "react";
import {
Button,
IconButton,
Dialog,
DialogContent,
} from "@mui/material";
import CloseIcon from "@mui/icons-material/Close";
import ImageGallery from "../ImageGallery";
import { ImageSearch } from "@mui/icons-material";
interface ImageGalleryModalProps {
handleCopy?: (id: string) => void;
}
const ImageGalleryModal: React.FC<ImageGalleryModalProps> = ({ handleCopy }) => {
const [open, setOpen] = useState<boolean>(false);
const handleOpen = () => setOpen(true);
const handleClose = () => setOpen(false);
return (
<>
<Button
variant="outlined"
aria-label='images-open'
onClick={() => handleOpen()}>
Images <ImageSearch />
</Button>
<Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
<DialogContent sx={{ display: "flex", flexDirection: "column", alignItems: "center", py: 3 }}>
<IconButton
onClick={handleClose}
color="primary"
aria-label="close"
sx={{
position: "absolute",
right: 8,
top: 8,
zIndex: 1,
}}
>
<CloseIcon />
</IconButton>
<ImageGallery handleCopy={handleCopy}/>
</DialogContent>
</Dialog>
</>
);
};
export default ImageGalleryModal;

View file

@ -47,10 +47,10 @@ const LaunchQuizDialog: React.FC<Props> = ({ open, handleOnClose, launchQuiz, se
<DialogActions> <DialogActions>
<Button variant="outlined" onClick={handleOnClose}> <Button variant="outlined" onClick={handleOnClose}>
<div>Annuler</div> Annuler
</Button> </Button>
<Button variant="contained" onClick={launchQuiz}> <Button variant="contained" onClick={launchQuiz}>
<div>Lancer</div> Lancer
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View file

@ -1,16 +1,26 @@
// LiveResults.tsx // LiveResults.tsx
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheck, faCircleXmark } from '@fortawesome/free-solid-svg-icons';
import { QuestionType } from '../../Types/QuestionType'; import { QuestionType } from '../../Types/QuestionType';
import './liveResult.css'; import './liveResult.css';
import { import {
FormControlLabel, FormControlLabel,
FormGroup, FormGroup,
Paper,
Switch, Switch,
Table,
TableBody,
TableCell,
TableContainer,
TableFooter,
TableHead,
TableRow
} from '@mui/material'; } from '@mui/material';
import { StudentType } from '../../Types/StudentType'; import { StudentType } from '../../Types/StudentType';
import { formatLatex } from '../GiftTemplate/templates/TextTypeTemplate';
import LiveResultsTable from './LiveResultsTable/LiveResultsTable';
interface LiveResultsProps { interface LiveResultsProps {
socket: Socket | null; socket: Socket | null;
@ -20,14 +30,241 @@ interface LiveResultsProps {
students: StudentType[] students: StudentType[]
} }
// interface Answer {
// answer: string | number | boolean;
// isCorrect: boolean;
// idQuestion: number;
// }
// interface StudentResult {
// username: string;
// idUser: string;
// answers: Answer[];
// }
const LiveResults: React.FC<LiveResultsProps> = ({ questions, showSelectedQuestion, students }) => { const LiveResults: React.FC<LiveResultsProps> = ({ questions, showSelectedQuestion, students }) => {
const [showUsernames, setShowUsernames] = useState<boolean>(false); const [showUsernames, setShowUsernames] = useState<boolean>(false);
const [showCorrectAnswers, setShowCorrectAnswers] = useState<boolean>(false); const [showCorrectAnswers, setShowCorrectAnswers] = useState<boolean>(false);
// const [students, setStudents] = useState<StudentType[]>(initialStudents);
// const [studentResultsMap, setStudentResultsMap] = useState<Map<string, StudentResult>>(new Map());
const maxQuestions = questions.length;
// useEffect(() => {
// // Initialize the map with the current students
// const newStudentResultsMap = new Map<string, StudentResult>();
// for (const student of students) {
// newStudentResultsMap.set(student.id, { username: student.name, idUser: student.id, answers: [] });
// }
// setStudentResultsMap(newStudentResultsMap);
// }, [])
// update when students change
// useEffect(() => {
// // studentResultsMap is inconsistent with students -- need to update
// for (const student of students as StudentType[]) {
// }
// }, [students])
// useEffect(() => {
// if (socket) {
// const submitAnswerHandler = ({
// idUser,
// answer,
// idQuestion
// }: {
// idUser: string;
// username: string;
// answer: string | number | boolean;
// idQuestion: number;
// }) => {
// console.log(`Received answer from ${idUser} for question ${idQuestion}: ${answer}`);
// // print the list of current student names
// console.log('Current students:');
// students.forEach((student) => {
// console.log(student.name);
// });
// // Update the students state using the functional form of setStudents
// setStudents((prevStudents) => {
// let foundStudent = false;
// const updatedStudents = prevStudents.map((student) => {
// if (student.id === idUser) {
// foundStudent = true;
// const updatedAnswers = student.answers.map((ans) => {
// const newAnswer: Answer = { answer, isCorrect: checkIfIsCorrect(answer, idQuestion), idQuestion };
// console.log(`Updating answer for ${student.name} for question ${idQuestion} to ${answer}`);
// return (ans.idQuestion === idQuestion ? { ...ans, newAnswer } : ans);
// }
// );
// return { ...student, answers: updatedAnswers };
// }
// return student;
// });
// if (!foundStudent) {
// console.log(`Student ${idUser} not found in the list of students in LiveResults`);
// }
// return updatedStudents;
// });
// // make a copy of the students array so we can update it
// // const updatedStudents = [...students];
// // const student = updatedStudents.find((student) => student.id === idUser);
// // if (!student) {
// // // this is a bad thing if an answer was submitted but the student isn't in the list
// // console.log(`Student ${idUser} not found in the list of students in LiveResults`);
// // return;
// // }
// // const isCorrect = checkIfIsCorrect(answer, idQuestion);
// // const newAnswer: Answer = { answer, isCorrect, idQuestion };
// // student.answers.push(newAnswer);
// // // print list of answers
// // console.log('Answers:');
// // student.answers.forEach((answer) => {
// // console.log(answer.answer);
// // });
// // setStudents(updatedStudents); // update the state
// };
// socket.on('submit-answer', submitAnswerHandler);
// return () => {
// socket.off('submit-answer');
// };
// }
// }, [socket]);
const getStudentGrade = (student: StudentType): number => {
if (student.answers.length === 0) {
return 0;
}
const uniqueQuestions = new Set();
let correctAnswers = 0;
for (const answer of student.answers) {
const { idQuestion, isCorrect } = answer;
if (!uniqueQuestions.has(idQuestion)) {
uniqueQuestions.add(idQuestion);
if (isCorrect) {
correctAnswers++;
}
}
}
return (correctAnswers / questions.length) * 100;
};
const classAverage: number = useMemo(() => {
let classTotal = 0;
students.forEach((student) => {
classTotal += getStudentGrade(student);
});
return classTotal / students.length;
}, [students]);
const getCorrectAnswersPerQuestion = (index: number): number => {
return (
(students.filter((student) =>
student.answers.some(
(answer) =>
parseInt(answer.idQuestion.toString()) === index + 1 && answer.isCorrect
)
).length / students.length) * 100
);
};
// (studentResults.filter((student) =>
// student.answers.some(
// (answer) =>
// parseInt(answer.idQuestion.toString()) === index + 1 && answer.isCorrect
// )
// ).length /
// studentResults.length) *
// 100
// );
// };
// function checkIfIsCorrect(answer: string | number | boolean, idQuestion: number): boolean {
// const questionInfo = questions.find((q) =>
// q.question.id ? q.question.id === idQuestion.toString() : false
// ) as QuestionType | undefined;
// const answerText = answer.toString();
// if (questionInfo) {
// const question = questionInfo.question as GIFTQuestion;
// if (question.type === 'TF') {
// return (
// (question.isTrue && answerText == 'true') ||
// (!question.isTrue && answerText == 'false')
// );
// } else if (question.type === 'MC') {
// return question.choices.some(
// (choice) => choice.isCorrect && choice.text.text === answerText
// );
// } else if (question.type === 'Numerical') {
// if (question.choices && !Array.isArray(question.choices)) {
// if (
// question.choices.type === 'high-low' &&
// question.choices.numberHigh &&
// question.choices.numberLow
// ) {
// const answerNumber = parseFloat(answerText);
// if (!isNaN(answerNumber)) {
// return (
// answerNumber <= question.choices.numberHigh &&
// answerNumber >= question.choices.numberLow
// );
// }
// }
// }
// if (question.choices && Array.isArray(question.choices)) {
// if (
// question.choices[0].text.type === 'range' &&
// question.choices[0].text.number &&
// question.choices[0].text.range
// ) {
// const answerNumber = parseFloat(answerText);
// const range = question.choices[0].text.range;
// const correctAnswer = question.choices[0].text.number;
// if (!isNaN(answerNumber)) {
// return (
// answerNumber <= correctAnswer + range &&
// answerNumber >= correctAnswer - range
// );
// }
// }
// if (
// question.choices[0].text.type === 'simple' &&
// question.choices[0].text.number
// ) {
// const answerNumber = parseFloat(answerText);
// if (!isNaN(answerNumber)) {
// return answerNumber === question.choices[0].text.number;
// }
// }
// }
// } else if (question.type === 'Short') {
// return question.choices.some(
// (choice) => choice.text.text.toUpperCase() === answerText.toUpperCase()
// );
// }
// }
// return false;
// }
return ( return (
<div> <div>
<div className="action-bar mb-1"> <div className="action-bar mb-1">
<div className="text-2xl text-bold">Résultats du quiz</div> <div className="text-2xl text-bold">Résultats du quiz</div>
@ -58,14 +295,146 @@ const LiveResults: React.FC<LiveResultsProps> = ({ questions, showSelectedQuesti
</div> </div>
<div className="table-container"> <div className="table-container">
<TableContainer component={Paper}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell className="sticky-column">
<div className="text-base text-bold">Nom d&apos;utilisateur</div>
</TableCell>
{Array.from({ length: maxQuestions }, (_, index) => (
<TableCell
key={index}
sx={{
textAlign: 'center',
cursor: 'pointer',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)'
}}
onClick={() => showSelectedQuestion(index)}
>
<div className="text-base text-bold blue">{`Q${index + 1}`}</div>
</TableCell>
))}
<TableCell
className="sticky-header"
sx={{
textAlign: 'center',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)'
}}
>
<div className="text-base text-bold">% réussite</div>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{students.map((student) => (
<TableRow key={student.id}>
<TableCell
className="sticky-column"
sx={{
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)'
}}
>
<div className="text-base">
{showUsernames ? student.name : '******'}
</div>
</TableCell>
{Array.from({ length: maxQuestions }, (_, index) => {
const answer = student.answers.find(
(answer) => parseInt(answer.idQuestion.toString()) === index + 1
);
const answerText = answer ? answer.answer.toString() : '';
const isCorrect = answer ? answer.isCorrect : false;
<LiveResultsTable return (
students={students} <TableCell
questions={questions} key={index}
showCorrectAnswers={showCorrectAnswers} sx={{
showSelectedQuestion={showSelectedQuestion} textAlign: 'center',
showUsernames={showUsernames} borderStyle: 'solid',
/> borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)'
}}
className={
answerText === ''
? ''
: isCorrect
? 'correct-answer'
: 'incorrect-answer'
}
>
{showCorrectAnswers ? (
<div>{formatLatex(answerText)}</div>
) : isCorrect ? (
<FontAwesomeIcon icon={faCheck} />
) : (
answerText !== '' && (
<FontAwesomeIcon icon={faCircleXmark} />
)
)}
</TableCell>
);
})}
<TableCell
sx={{
textAlign: 'center',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)',
fontWeight: 'bold',
color: 'rgba(0, 0, 0)'
}}
>
{getStudentGrade(student).toFixed()} %
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow sx={{ backgroundColor: '#d3d3d34f' }}>
<TableCell className="sticky-column" sx={{ color: 'black' }}>
<div className="text-base text-bold">% réussite</div>
</TableCell>
{Array.from({ length: maxQuestions }, (_, index) => (
<TableCell
key={index}
sx={{
textAlign: 'center',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)',
fontWeight: 'bold',
color: 'rgba(0, 0, 0)'
}}
>
{students.length > 0
? `${getCorrectAnswersPerQuestion(index).toFixed()} %`
: '-'}
</TableCell>
))}
<TableCell
sx={{
textAlign: 'center',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)',
fontWeight: 'bold',
fontSize: '1rem',
color: 'rgba(0, 0, 0)'
}}
>
{students.length > 0 ? `${classAverage.toFixed()} %` : '-'}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</TableContainer>
</div> </div>
</div> </div>
); );

View file

@ -1,75 +0,0 @@
import React from 'react';
import { Paper, Table, TableContainer } from '@mui/material';
import { StudentType } from 'src/Types/StudentType';
import { QuestionType } from '../../../Types/QuestionType';
import LiveResultsTableFooter from './TableComponents/LiveResultTableFooter';
import LiveResultsTableHeader from './TableComponents/LiveResultsTableHeader';
import LiveResultsTableBody from './TableComponents/LiveResultsTableBody';
interface LiveResultsTableProps {
students: StudentType[];
questions: QuestionType[];
showCorrectAnswers: boolean;
showSelectedQuestion: (index: number) => void;
showUsernames: boolean;
}
const LiveResultsTable: React.FC<LiveResultsTableProps> = ({
questions,
students,
showSelectedQuestion,
showUsernames,
showCorrectAnswers
}) => {
const maxQuestions = questions.length;
const getStudentGrade = (student: StudentType): number => {
if (student.answers.length === 0) {
return 0;
}
const uniqueQuestions = new Set();
let correctAnswers = 0;
for (const answer of student.answers) {
const { idQuestion, isCorrect } = answer;
if (!uniqueQuestions.has(idQuestion)) {
uniqueQuestions.add(idQuestion);
if (isCorrect) {
correctAnswers++;
}
}
}
return (correctAnswers / questions.length) * 100;
};
return (
<TableContainer component={Paper}>
<Table size="small">
<LiveResultsTableHeader
maxQuestions={maxQuestions}
showSelectedQuestion={showSelectedQuestion}
/>
<LiveResultsTableBody
maxQuestions={maxQuestions}
students={students}
showUsernames={showUsernames}
showCorrectAnswers={showCorrectAnswers}
getStudentGrade={getStudentGrade}
/>
<LiveResultsTableFooter
students={students}
maxQuestions={maxQuestions}
getStudentGrade={getStudentGrade}
/>
</Table>
</TableContainer>
);
};
export default LiveResultsTable;

View file

@ -1,79 +0,0 @@
import { TableCell, TableFooter, TableRow } from "@mui/material";
import React, { useMemo } from "react";
import { StudentType } from "src/Types/StudentType";
interface LiveResultsFooterProps {
students: StudentType[];
maxQuestions: number;
getStudentGrade: (student: StudentType) => number;
}
const LiveResultsTableFooter: React.FC<LiveResultsFooterProps> = ({
maxQuestions,
students,
getStudentGrade
}) => {
const getCorrectAnswersPerQuestion = (index: number): number => {
return (
(students.filter((student) =>
student.answers.some(
(answer) =>
parseInt(answer.idQuestion.toString()) === index + 1 && answer.isCorrect
)
).length / students.length) * 100
);
};
const classAverage: number = useMemo(() => {
let classTotal = 0;
students.forEach((student) => {
classTotal += getStudentGrade(student);
});
return classTotal / students.length;
}, [students]);
return (
<TableFooter>
<TableRow sx={{ backgroundColor: '#d3d3d34f' }}>
<TableCell className="sticky-column" sx={{ color: 'black' }}>
<div className="text-base text-bold">% réussite</div>
</TableCell>
{Array.from({ length: maxQuestions }, (_, index) => (
<TableCell
key={index}
sx={{
textAlign: 'center',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)',
fontWeight: 'bold',
color: 'rgba(0, 0, 0)',
}}
>
{students.length > 0
? `${getCorrectAnswersPerQuestion(index).toFixed()} %`
: '-'}
</TableCell>
))}
<TableCell
sx={{
textAlign: 'center',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)',
fontWeight: 'bold',
fontSize: '1rem',
color: 'rgba(0, 0, 0)',
}}
>
{students.length > 0 ? `${classAverage.toFixed()} %` : '-'}
</TableCell>
</TableRow>
</TableFooter>
);
};
export default LiveResultsTableFooter;

View file

@ -1,94 +0,0 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { TableBody, TableCell, TableRow } from "@mui/material";
import { faCheck, faCircleXmark } from '@fortawesome/free-solid-svg-icons';
import { FormattedTextTemplate } from '../../../GiftTemplate/templates/TextTypeTemplate';
import React from "react";
import { StudentType } from "src/Types/StudentType";
interface LiveResultsFooterProps {
maxQuestions: number;
students: StudentType[];
showUsernames: boolean;
showCorrectAnswers: boolean;
getStudentGrade: (student: StudentType) => number;
}
const LiveResultsTableFooter: React.FC<LiveResultsFooterProps> = ({
maxQuestions,
students,
showUsernames,
showCorrectAnswers,
getStudentGrade
}) => {
return (
<TableBody>
{students.map((student) => (
<TableRow key={student.id}>
<TableCell
className="sticky-column"
sx={{
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)'
}}
>
<div className="text-base">
{showUsernames ? student.name : '******'}
</div>
</TableCell>
{Array.from({ length: maxQuestions }, (_, index) => {
const answer = student.answers.find(
(answer) => parseInt(answer.idQuestion.toString()) === index + 1
);
const answerText = answer ? answer.answer.toString() : '';
const isCorrect = answer ? answer.isCorrect : false;
return (
<TableCell
key={index}
sx={{
textAlign: 'center',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)'
}}
className={
answerText === ''
? ''
: isCorrect
? 'correct-answer'
: 'incorrect-answer'
}
>
{showCorrectAnswers ? (
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate({ format: '', text: answerText }) }}></div>
) : isCorrect ? (
<FontAwesomeIcon icon={faCheck} aria-label="correct" />
) : (
answerText !== '' && (
<FontAwesomeIcon icon={faCircleXmark} aria-label="incorrect"/>
)
)}
</TableCell>
);
})}
<TableCell
sx={{
textAlign: 'center',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)',
fontWeight: 'bold',
color: 'rgba(0, 0, 0)'
}}
>
{getStudentGrade(student).toFixed()} %
</TableCell>
</TableRow>
))}
</TableBody>
);
};
export default LiveResultsTableFooter;

View file

@ -1,50 +0,0 @@
import React from "react";
import { TableCell, TableHead, TableRow } from "@mui/material";
interface LiveResultsHeaderProps {
maxQuestions: number;
showSelectedQuestion: (index: number) => void;
}
const LiveResultsTableHeader: React.FC<LiveResultsHeaderProps> = ({
maxQuestions,
showSelectedQuestion,
}) => {
return (
<TableHead>
<TableRow>
<TableCell className="sticky-column">
<div className="text-base text-bold">Nom d&apos;utilisateur</div>
</TableCell>
{Array.from({ length: maxQuestions }, (_, index) => (
<TableCell
key={index}
sx={{
textAlign: 'center',
cursor: 'pointer',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)'
}}
onClick={() => showSelectedQuestion(index)}
>
<div className="text-base text-bold blue">{`Q${index + 1}`}</div>
</TableCell>
))}
<TableCell
className="sticky-header"
sx={{
textAlign: 'center',
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)'
}}
>
<div className="text-base text-bold">% réussite</div>
</TableCell>
</TableRow>
</TableHead>
);
};
export default LiveResultsTableHeader;

View file

@ -4,64 +4,27 @@ import '../questionStyle.css';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate'; import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate';
import { MultipleChoiceQuestion } from 'gift-pegjs'; import { MultipleChoiceQuestion } from 'gift-pegjs';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface Props { interface Props {
question: MultipleChoiceQuestion; question: MultipleChoiceQuestion;
handleOnSubmitAnswer?: (answer: AnswerType) => void; handleOnSubmitAnswer?: (answer: string) => void;
showAnswer?: boolean; showAnswer?: boolean;
passedAnswer?: AnswerType;
} }
const MultipleChoiceQuestionDisplay: React.FC<Props> = (props) => { const MultipleChoiceQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = props; const { question, showAnswer, handleOnSubmitAnswer } = props;
console.log('MultipleChoiceQuestionDisplay: passedAnswer', JSON.stringify(passedAnswer)); const [answer, setAnswer] = useState<string>();
const [answer, setAnswer] = useState<AnswerType>(() => {
if (passedAnswer && passedAnswer.length > 0) {
return passedAnswer;
}
return [];
});
let disableButton = false;
if (handleOnSubmitAnswer === undefined) {
disableButton = true;
}
useEffect(() => { useEffect(() => {
console.log('MultipleChoiceQuestionDisplay: passedAnswer', JSON.stringify(passedAnswer)); setAnswer(undefined);
if (passedAnswer !== undefined) { }, [question]);
setAnswer(passedAnswer);
} else {
setAnswer([]);
}
}, [passedAnswer, question.id]);
const handleOnClickAnswer = (choice: string) => { const handleOnClickAnswer = (choice: string) => {
setAnswer((prevAnswer) => { setAnswer(choice);
console.log(`handleOnClickAnswer -- setAnswer(): prevAnswer: ${prevAnswer}, choice: ${choice}`);
const correctAnswersCount = question.choices.filter((c) => c.isCorrect).length;
if (correctAnswersCount === 1) {
// If only one correct answer, replace the current selection
return prevAnswer.includes(choice) ? [] : [choice];
} else {
// Allow multiple selections if there are multiple correct answers
if (prevAnswer.includes(choice)) {
// Remove the choice if it's already selected
return prevAnswer.filter((selected) => selected !== choice);
} else {
// Add the choice if it's not already selected
return [...prevAnswer, choice];
}
}
});
}; };
const alpha = Array.from(Array(26)).map((_e, i) => i + 65); const alpha = Array.from(Array(26)).map((_e, i) => i + 65);
const alphabet = alpha.map((x) => String.fromCharCode(x)); const alphabet = alpha.map((x) => String.fromCharCode(x));
return ( return (
<div className="question-container"> <div className="question-container">
<div className="question content"> <div className="question content">
@ -69,61 +32,47 @@ const MultipleChoiceQuestionDisplay: React.FC<Props> = (props) => {
</div> </div>
<div className="choices-wrapper mb-1"> <div className="choices-wrapper mb-1">
{question.choices.map((choice, i) => { {question.choices.map((choice, i) => {
console.log(`answer: ${answer}, choice: ${choice.formattedText.text}`); const selected = answer === choice.formattedText.text ? 'selected' : '';
const selected = answer.includes(choice.formattedText.text) ? 'selected' : '';
return ( return (
<div key={choice.formattedText.text + i} className="choice-container"> <div key={choice.formattedText.text + i} className="choice-container">
<Button <Button
variant="text" variant="text"
className="button-wrapper" className="button-wrapper"
disabled={disableButton} onClick={() => !showAnswer && handleOnClickAnswer(choice.formattedText.text)}>
onClick={() => !showAnswer && handleOnClickAnswer(choice.formattedText.text)} {showAnswer? (<div> {(choice.isCorrect ? '✅' : '❌')}</div>)
> :``}
{showAnswer ? (
<div>{choice.isCorrect ? '✅' : '❌'}</div>
) : (
''
)}
<div className={`circle ${selected}`}>{alphabet[i]}</div> <div className={`circle ${selected}`}>{alphabet[i]}</div>
<div className={`answer-text ${selected}`}> <div className={`answer-text ${selected}`}>
<div <div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(choice.formattedText) }} />
dangerouslySetInnerHTML={{
__html: FormattedTextTemplate(choice.formattedText),
}}
/>
</div> </div>
{choice.formattedFeedback && showAnswer && ( {choice.formattedFeedback && showAnswer && (
<div className="feedback-container mb-1 mt-1/2"> <div className="feedback-container mb-1 mt-1/2">
<div <div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(choice.formattedFeedback) }} />
dangerouslySetInnerHTML={{ </div>
__html: FormattedTextTemplate(choice.formattedFeedback), )}
}}
/>
</div>
)}
</Button> </Button>
</div> </div>
); );
})} })}
</div> </div>
{question.formattedGlobalFeedback && showAnswer && ( {question.formattedGlobalFeedback && showAnswer && (
<div className="global-feedback mb-2"> <div className="global-feedback mb-2">
<div <div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} />
dangerouslySetInnerHTML={{ </div>
__html: FormattedTextTemplate(question.formattedGlobalFeedback),
}}
/>
</div>
)} )}
{!showAnswer && handleOnSubmitAnswer && ( {!showAnswer && handleOnSubmitAnswer && (
<Button <Button
variant="contained" variant="contained"
onClick={() => onClick={() =>
answer.length > 0 && handleOnSubmitAnswer && handleOnSubmitAnswer(answer) answer !== undefined && handleOnSubmitAnswer && handleOnSubmitAnswer(answer)
} }
disabled={answer.length === 0} disabled={answer === undefined}
> >
Répondre Répondre
</Button> </Button>
)} )}
</div> </div>

View file

@ -1,32 +1,26 @@
// NumericalQuestion.tsx // NumericalQuestion.tsx
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import '../questionStyle.css'; import '../questionStyle.css';
import { Button, TextField } from '@mui/material'; import { Button, TextField } from '@mui/material';
import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate'; import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate';
import { NumericalQuestion, SimpleNumericalAnswer, RangeNumericalAnswer, HighLowNumericalAnswer } from 'gift-pegjs'; import { NumericalQuestion, SimpleNumericalAnswer, RangeNumericalAnswer, HighLowNumericalAnswer } from 'gift-pegjs';
import { isSimpleNumericalAnswer, isRangeNumericalAnswer, isHighLowNumericalAnswer, isMultipleNumericalAnswer } from 'gift-pegjs/typeGuards'; import { isSimpleNumericalAnswer, isRangeNumericalAnswer, isHighLowNumericalAnswer, isMultipleNumericalAnswer } from 'gift-pegjs/typeGuards';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface Props { interface Props {
question: NumericalQuestion; question: NumericalQuestion;
handleOnSubmitAnswer?: (answer: AnswerType) => void; handleOnSubmitAnswer?: (answer: number) => void;
showAnswer?: boolean; showAnswer?: boolean;
passedAnswer?: AnswerType;
} }
const NumericalQuestionDisplay: React.FC<Props> = (props) => { const NumericalQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = const { question, showAnswer, handleOnSubmitAnswer } =
props; props;
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || []);
const [answer, setAnswer] = useState<number>();
const correctAnswers = question.choices; const correctAnswers = question.choices;
let correctAnswer = ''; let correctAnswer = '';
useEffect(() => {
if (passedAnswer !== null && passedAnswer !== undefined) {
setAnswer(passedAnswer);
}
}, [passedAnswer]);
//const isSingleAnswer = correctAnswers.length === 1; //const isSingleAnswer = correctAnswers.length === 1;
if (isSimpleNumericalAnswer(correctAnswers[0])) { if (isSimpleNumericalAnswer(correctAnswers[0])) {
@ -50,16 +44,10 @@ const NumericalQuestionDisplay: React.FC<Props> = (props) => {
</div> </div>
{showAnswer ? ( {showAnswer ? (
<> <>
<div className="correct-answer-text mb-2"> <div className="correct-answer-text mb-2">{correctAnswer}</div>
<strong>La bonne réponse est: </strong>
{correctAnswer}</div>
<span>
<strong>Votre réponse est: </strong>{answer.toString()}
</span>
{question.formattedGlobalFeedback && <div className="global-feedback mb-2"> {question.formattedGlobalFeedback && <div className="global-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} /> <div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} />
</div>} </div>}
</> </>
) : ( ) : (
<> <>
@ -69,7 +57,7 @@ const NumericalQuestionDisplay: React.FC<Props> = (props) => {
id={question.formattedStem.text} id={question.formattedStem.text}
name={question.formattedStem.text} name={question.formattedStem.text}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => { onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
setAnswer([e.target.valueAsNumber]); setAnswer(e.target.valueAsNumber);
}} }}
inputProps={{ 'data-testid': 'number-input' }} inputProps={{ 'data-testid': 'number-input' }}
/> />
@ -87,7 +75,7 @@ const NumericalQuestionDisplay: React.FC<Props> = (props) => {
handleOnSubmitAnswer && handleOnSubmitAnswer &&
handleOnSubmitAnswer(answer) handleOnSubmitAnswer(answer)
} }
disabled={answer === undefined || answer === null || isNaN(answer[0] as number)} disabled={answer === undefined || isNaN(answer)}
> >
Répondre Répondre
</Button> </Button>

View file

@ -5,21 +5,17 @@ import TrueFalseQuestionDisplay from './TrueFalseQuestionDisplay/TrueFalseQuesti
import MultipleChoiceQuestionDisplay from './MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay'; import MultipleChoiceQuestionDisplay from './MultipleChoiceQuestionDisplay/MultipleChoiceQuestionDisplay';
import NumericalQuestionDisplay from './NumericalQuestionDisplay/NumericalQuestionDisplay'; import NumericalQuestionDisplay from './NumericalQuestionDisplay/NumericalQuestionDisplay';
import ShortAnswerQuestionDisplay from './ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay'; import ShortAnswerQuestionDisplay from './ShortAnswerQuestionDisplay/ShortAnswerQuestionDisplay';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
// import useCheckMobileScreen from '../../services/useCheckMobileScreen'; // import useCheckMobileScreen from '../../services/useCheckMobileScreen';
interface QuestionProps { interface QuestionProps {
question: Question; question: Question;
handleOnSubmitAnswer?: (answer: AnswerType) => void; handleOnSubmitAnswer?: (answer: string | number | boolean) => void;
showAnswer?: boolean; showAnswer?: boolean;
answer?: AnswerType;
} }
const QuestionDisplay: React.FC<QuestionProps> = ({ const QuestionDisplay: React.FC<QuestionProps> = ({
question, question,
handleOnSubmitAnswer, handleOnSubmitAnswer,
showAnswer, showAnswer,
answer,
}) => { }) => {
// const isMobile = useCheckMobileScreen(); // const isMobile = useCheckMobileScreen();
// const imgWidth = useMemo(() => { // const imgWidth = useMemo(() => {
@ -34,32 +30,37 @@ const QuestionDisplay: React.FC<QuestionProps> = ({
question={question} question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer} showAnswer={showAnswer}
passedAnswer={answer}
/> />
); );
break; break;
case 'MC': case 'MC':
questionTypeComponent = ( questionTypeComponent = (
<MultipleChoiceQuestionDisplay <MultipleChoiceQuestionDisplay
question={question} question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer} showAnswer={showAnswer}
passedAnswer={answer}
/> />
); );
break; break;
case 'Numerical': case 'Numerical':
if (question.choices) { if (question.choices) {
if (!Array.isArray(question.choices)) {
questionTypeComponent = ( questionTypeComponent = (
<NumericalQuestionDisplay <NumericalQuestionDisplay
question={question} question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer} showAnswer={showAnswer}
passedAnswer={answer}
/> />
); );
} else {
questionTypeComponent = ( // TODO fix NumericalQuestion (correctAnswers is borked)
<NumericalQuestionDisplay
question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer}
/>
);
}
} }
break; break;
case 'Short': case 'Short':
@ -68,7 +69,6 @@ const QuestionDisplay: React.FC<QuestionProps> = ({
question={question} question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer} showAnswer={showAnswer}
passedAnswer={answer}
/> />
); );
break; break;

View file

@ -1,29 +1,18 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import '../questionStyle.css'; import '../questionStyle.css';
import { Button, TextField } from '@mui/material'; import { Button, TextField } from '@mui/material';
import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate'; import { FormattedTextTemplate } from '../../GiftTemplate/templates/TextTypeTemplate';
import { ShortAnswerQuestion } from 'gift-pegjs'; import { ShortAnswerQuestion } from 'gift-pegjs';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface Props { interface Props {
question: ShortAnswerQuestion; question: ShortAnswerQuestion;
handleOnSubmitAnswer?: (answer: AnswerType) => void; handleOnSubmitAnswer?: (answer: string) => void;
showAnswer?: boolean; showAnswer?: boolean;
passedAnswer?: AnswerType;
} }
const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => { const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer } = props;
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = props; const [answer, setAnswer] = useState<string>();
const [answer, setAnswer] = useState<AnswerType>(passedAnswer || []);
useEffect(() => {
if (passedAnswer !== undefined) {
setAnswer(passedAnswer);
}
}, [passedAnswer]);
console.log("Answer" , answer);
return ( return (
<div className="question-wrapper"> <div className="question-wrapper">
@ -33,18 +22,11 @@ const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
{showAnswer ? ( {showAnswer ? (
<> <>
<div className="correct-answer-text mb-1"> <div className="correct-answer-text mb-1">
<span>
<strong>La bonne réponse est: </strong>
{question.choices.map((choice) => ( {question.choices.map((choice) => (
<div key={choice.text} className="mb-1"> <div key={choice.text} className="mb-1">
{choice.text} {choice.text}
</div> </div>
))} ))}
</span>
<span>
<strong>Votre réponse est: </strong>{answer}
</span>
</div> </div>
{question.formattedGlobalFeedback && <div className="global-feedback mb-2"> {question.formattedGlobalFeedback && <div className="global-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} /> <div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} />
@ -58,7 +40,7 @@ const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
id={question.formattedStem.text} id={question.formattedStem.text}
name={question.formattedStem.text} name={question.formattedStem.text}
onChange={(e) => { onChange={(e) => {
setAnswer([e.target.value]); setAnswer(e.target.value);
}} }}
disabled={showAnswer} disabled={showAnswer}
aria-label="short-answer-input" aria-label="short-answer-input"
@ -72,7 +54,7 @@ const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
handleOnSubmitAnswer && handleOnSubmitAnswer &&
handleOnSubmitAnswer(answer) handleOnSubmitAnswer(answer)
} }
disabled={answer === null || answer === undefined || answer.length === 0} disabled={answer === undefined || answer === ''}
> >
Répondre Répondre
</Button> </Button>

View file

@ -4,86 +4,61 @@ import '../questionStyle.css';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import { TrueFalseQuestion } from 'gift-pegjs'; import { TrueFalseQuestion } from 'gift-pegjs';
import { FormattedTextTemplate } from 'src/components/GiftTemplate/templates/TextTypeTemplate'; import { FormattedTextTemplate } from 'src/components/GiftTemplate/templates/TextTypeTemplate';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface Props { interface Props {
question: TrueFalseQuestion; question: TrueFalseQuestion;
handleOnSubmitAnswer?: (answer: AnswerType) => void; handleOnSubmitAnswer?: (answer: boolean) => void;
showAnswer?: boolean; showAnswer?: boolean;
passedAnswer?: AnswerType;
} }
const TrueFalseQuestionDisplay: React.FC<Props> = (props) => { const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = const { question, showAnswer, handleOnSubmitAnswer } =
props; props;
const [answer, setAnswer] = useState<boolean | undefined>(undefined);
const [answer, setAnswer] = useState<boolean | undefined>(() => {
if (passedAnswer && (passedAnswer[0] === true || passedAnswer[0] === false)) {
return passedAnswer[0];
}
return undefined;
});
let disableButton = false;
if (handleOnSubmitAnswer === undefined) {
disableButton = true;
}
useEffect(() => { useEffect(() => {
console.log("passedAnswer", passedAnswer); setAnswer(undefined);
if (passedAnswer && (passedAnswer[0] === true || passedAnswer[0] === false)) { }, [question]);
setAnswer(passedAnswer[0]);
} else {
setAnswer(undefined);
}
}, [passedAnswer, question.id]);
const handleOnClickAnswer = (choice: boolean) => {
setAnswer(choice);
};
const selectedTrue = answer ? 'selected' : ''; const selectedTrue = answer ? 'selected' : '';
const selectedFalse = answer !== undefined && !answer ? 'selected' : ''; const selectedFalse = answer !== undefined && !answer ? 'selected' : '';
return ( return (
<div className="question-container"> <div className="question-container">
<div className="question content"> <div className="question content">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedStem) }} /> <div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedStem) }} />
</div> </div>
<div className="choices-wrapper mb-1"> <div className="choices-wrapper mb-1">
<Button <Button
className="button-wrapper" className="button-wrapper"
onClick={() => !showAnswer && handleOnClickAnswer(true)} onClick={() => !showAnswer && setAnswer(true)}
fullWidth fullWidth
disabled={disableButton}
> >
{showAnswer ? (<div> {(question.isTrue ? '✅' : '❌')}</div>) : ``} {showAnswer? (<div> {(question.isTrue ? '✅' : '❌')}</div>):``}
<div className={`circle ${selectedTrue}`}>V</div>
<div className={`answer-text ${selectedTrue}`}>Vrai</div> <div className={`answer-text ${selectedTrue}`}>Vrai</div>
{showAnswer && answer && question.trueFormattedFeedback && (
<div className="true-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.trueFormattedFeedback) }} />
</div>
)}
</Button> </Button>
<Button <Button
className="button-wrapper" className="button-wrapper"
onClick={() => !showAnswer && handleOnClickAnswer(false)} onClick={() => !showAnswer && setAnswer(false)}
fullWidth fullWidth
disabled={disableButton}
> >
{showAnswer ? (<div> {(!question.isTrue ? '✅' : '❌')}</div>) : ``} {showAnswer? (<div> {(!question.isTrue ? '✅' : '❌')}</div>):``}
<div className={`circle ${selectedFalse}`}>F</div>
<div className={`answer-text ${selectedFalse}`}>Faux</div> <div className={`answer-text ${selectedFalse}`}>Faux</div>
{showAnswer && !answer && question.falseFormattedFeedback && (
<div className="false-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.falseFormattedFeedback) }} />
</div>
)}
</Button> </Button>
</div> </div>
{/* selected TRUE, show True feedback if it exists */}
{showAnswer && answer && question.trueFormattedFeedback && (
<div className="true-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.trueFormattedFeedback) }} />
</div>
)}
{/* selected FALSE, show False feedback if it exists */}
{showAnswer && !answer && question.falseFormattedFeedback && (
<div className="false-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.falseFormattedFeedback) }} />
</div>
)}
{question.formattedGlobalFeedback && showAnswer && ( {question.formattedGlobalFeedback && showAnswer && (
<div className="global-feedback mb-2"> <div className="global-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} /> <div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} />
@ -93,7 +68,7 @@ const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
<Button <Button
variant="contained" variant="contained"
onClick={() => onClick={() =>
answer !== undefined && handleOnSubmitAnswer && handleOnSubmitAnswer([answer]) answer !== undefined && handleOnSubmitAnswer && handleOnSubmitAnswer(answer)
} }
disabled={answer === undefined} disabled={answer === undefined}
> >

View file

@ -27,6 +27,7 @@
} }
.question-wrapper .katex { .question-wrapper .katex {
display: block;
text-align: center; text-align: center;
} }
@ -119,9 +120,9 @@
} }
.feedback-container { .feedback-container {
display: inline-block !important; /* override the parent */
align-items: center;
margin-left: 1.1rem; margin-left: 1.1rem;
display: inline-flex !important; /* override the parent */
align-items: center;
position: relative; position: relative;
padding: 0 0.5rem; padding: 0 0.5rem;
background-color: hsl(43, 100%, 94%); background-color: hsl(43, 100%, 94%);
@ -147,25 +148,6 @@
box-shadow: 0px 2px 5px hsl(0, 0%, 74%); box-shadow: 0px 2px 5px hsl(0, 0%, 74%);
} }
.true-feedback {
position: relative;
padding: 0 1rem;
background-color: hsl(43, 100%, 94%);
color: hsl(43, 95%, 9%);
border: hsl(36, 84%, 93%) 1px solid;
border-radius: 6px;
box-shadow: 0px 2px 5px hsl(0, 0%, 74%);
}
.false-feedback {
position: relative;
padding: 0 1rem;
background-color: hsl(43, 100%, 94%);
color: hsl(43, 95%, 9%);
border: hsl(36, 84%, 93%) 1px solid;
border-radius: 6px;
box-shadow: 0px 2px 5px hsl(0, 0%, 74%);
}
.choices-wrapper { .choices-wrapper {
width: 90%; width: 90%;
} }

View file

@ -1,94 +0,0 @@
import React, { useState } from 'react';
import { Dialog, DialogTitle, DialogActions, Button, Tooltip, IconButton, Typography, Box } from '@mui/material';
import { Share } from '@mui/icons-material';
import { QuizType } from '../../Types/QuizType';
interface ShareQuizModalProps {
quiz: QuizType;
}
const ShareQuizModal: React.FC<ShareQuizModalProps> = ({ quiz }) => {
const [_open, setOpen] = useState(false);
const [feedback, setFeedback] = useState({
open: false,
title: '',
isError: false
});
const handleCloseModal = () => setOpen(false);
const handleShareByUrl = () => {
const quizUrl = `${window.location.origin}/teacher/share/${quiz._id}`;
navigator.clipboard.writeText(quizUrl)
.then(() => {
setFeedback({
open: true,
title: 'L\'URL de partage pour le quiz',
isError: false
});
})
.catch(() => {
setFeedback({
open: true,
title: 'Une erreur est survenue lors de la copie de l\'URL.',
isError: true
});
});
handleCloseModal();
};
const closeFeedback = () => {
setFeedback(prev => ({ ...prev, open: false }));
};
return (
<>
<Tooltip title="Partager" placement="top">
<IconButton color="primary" onClick={handleShareByUrl} aria-label="partager quiz">
<Share />
</IconButton>
</Tooltip>
{/* Feedback Dialog */}
<Dialog
open={feedback.open}
onClose={closeFeedback}
fullWidth
maxWidth="xs"
>
<DialogTitle sx={{ textAlign: "center" }}>
<Box>
{feedback.isError ? (
<Typography color="error.main">
{feedback.title}
</Typography>
) : (
<>
<Typography component="span">
L'URL de partage pour le quiz{' '}
</Typography>
<Typography component="span" fontWeight="bold">
{quiz.title}
</Typography>
<Typography component="span">
{' '}a é copiée.
</Typography>
</>
)}
</Box>
</DialogTitle>
<DialogActions sx={{ display: "flex", justifyContent: "center" }}>
<Button
onClick={closeFeedback}
variant="contained"
>
OK
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default ShareQuizModal;

View file

@ -3,47 +3,41 @@ import React, { useEffect, useState } from 'react';
import QuestionComponent from '../QuestionsDisplay/QuestionDisplay'; import QuestionComponent from '../QuestionsDisplay/QuestionDisplay';
import '../../pages/Student/JoinRoom/joinRoom.css'; import '../../pages/Student/JoinRoom/joinRoom.css';
import { QuestionType } from '../../Types/QuestionType'; import { QuestionType } from '../../Types/QuestionType';
// import { QuestionService } from '../../services/QuestionService';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
//import QuestionNavigation from '../QuestionNavigation/QuestionNavigation'; //import QuestionNavigation from '../QuestionNavigation/QuestionNavigation';
//import { ChevronLeft, ChevronRight } from '@mui/icons-material';
import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton'; import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton';
import { Question } from 'gift-pegjs'; import { Question } from 'gift-pegjs';
import { AnswerSubmissionToBackendType } from 'src/services/WebsocketService';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface StudentModeQuizProps { interface StudentModeQuizProps {
questions: QuestionType[]; questions: QuestionType[];
answers: AnswerSubmissionToBackendType[]; submitAnswer: (answer: string | number | boolean, idQuestion: number) => void;
submitAnswer: (_answer: AnswerType, _idQuestion: number) => void;
disconnectWebSocket: () => void; disconnectWebSocket: () => void;
} }
const StudentModeQuiz: React.FC<StudentModeQuizProps> = ({ const StudentModeQuiz: React.FC<StudentModeQuizProps> = ({
questions, questions,
answers,
submitAnswer, submitAnswer,
disconnectWebSocket disconnectWebSocket
}) => { }) => {
//Ajouter type AnswerQuestionType en remplacement de QuestionType
const [questionInfos, setQuestion] = useState<QuestionType>(questions[0]); const [questionInfos, setQuestion] = useState<QuestionType>(questions[0]);
const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false); const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false);
// const [answer, setAnswer] = useState<AnswerType>(''); // const [imageUrl, setImageUrl] = useState('');
const previousQuestion = () => { // const previousQuestion = () => {
setQuestion(questions[Number(questionInfos.question?.id) - 2]); // setQuestion(questions[Number(questionInfos.question?.id) - 2]);
}; // setIsAnswerSubmitted(false);
// };
useEffect(() => { useEffect(() => {}, [questionInfos]);
const savedAnswer = answers[Number(questionInfos.question.id)-1]?.answer;
console.log(`StudentModeQuiz: useEffect: savedAnswer: ${savedAnswer}`);
setIsAnswerSubmitted(savedAnswer !== undefined);
}, [questionInfos.question, answers]);
const nextQuestion = () => { const nextQuestion = () => {
setQuestion(questions[Number(questionInfos.question?.id)]); setQuestion(questions[Number(questionInfos.question?.id)]);
setIsAnswerSubmitted(false);
}; };
const handleOnSubmitAnswer = (answer: AnswerType) => { const handleOnSubmitAnswer = (answer: string | number | boolean) => {
const idQuestion = Number(questionInfos.question.id) || -1; const idQuestion = Number(questionInfos.question.id) || -1;
submitAnswer(answer, idQuestion); submitAnswer(answer, idQuestion);
setIsAnswerSubmitted(true); setIsAnswerSubmitted(true);
@ -52,13 +46,11 @@ const StudentModeQuiz: React.FC<StudentModeQuizProps> = ({
return ( return (
<div className='room'> <div className='room'>
<div className='roomHeader'> <div className='roomHeader'>
<DisconnectButton <DisconnectButton
onReturn={disconnectWebSocket} onReturn={disconnectWebSocket}
message={`Êtes-vous sûr de vouloir quitter?`} /> message={`Êtes-vous sûr de vouloir quitter?`} />
</div>
<div >
<b>Question {questionInfos.question.id}/{questions.length}</b>
</div> </div>
<div className="overflow-auto"> <div className="overflow-auto">
<div className="question-component-container"> <div className="question-component-container">
@ -74,30 +66,31 @@ const StudentModeQuiz: React.FC<StudentModeQuizProps> = ({
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
question={questionInfos.question as Question} question={questionInfos.question as Question}
showAnswer={isAnswerSubmitted} showAnswer={isAnswerSubmitted}
answer={answers[Number(questionInfos.question.id)-1]?.answer}
/> />
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginTop: '1rem' }}> <div className="center-h-align mt-1/2">
<div> <div className="w-12">
<Button {/* <Button
variant="outlined" variant="outlined"
onClick={previousQuestion} onClick={previousQuestion}
fullWidth fullWidth
disabled={Number(questionInfos.question.id) <= 1} startIcon={<ChevronLeft />}
> disabled={Number(questionInfos.question.id) <= 1}
Question précédente >
</Button> Question précédente
</Button> */}
</div>
<div className="w-12">
<Button style={{ display: isAnswerSubmitted ? 'block' : 'none' }}
variant="outlined"
onClick={nextQuestion}
fullWidth
//endIcon={<ChevronRight />}
disabled={Number(questionInfos.question.id) >= questions.length}
>
Question suivante
</Button>
</div>
</div> </div>
<div>
<Button
variant="outlined"
onClick={nextQuestion}
fullWidth
disabled={Number(questionInfos.question.id) >= questions.length}
>
Question suivante
</Button>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -9,26 +9,23 @@ import './studentWaitPage.css';
interface Props { interface Props {
students: StudentType[]; students: StudentType[];
launchQuiz: () => void; launchQuiz: () => void;
setQuizMode: (_mode: 'student' | 'teacher') => void; setQuizMode: (mode: 'student' | 'teacher') => void;
} }
const StudentWaitPage: React.FC<Props> = ({ students, launchQuiz, setQuizMode }) => { const StudentWaitPage: React.FC<Props> = ({ students, launchQuiz, setQuizMode }) => {
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false); const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const handleLaunchClick = () => {
setIsDialogOpen(true);
};
return ( return (
<div className="wait"> <div className="wait">
<div className='button'> <div className='button'>
<Button <Button
variant="contained" variant="contained"
onClick={handleLaunchClick} onClick={() => setIsDialogOpen(true)}
startIcon={<PlayArrow />} startIcon={<PlayArrow />}
sx={{ fontWeight: 600, fontSize: 20, width: 'auto' }} fullWidth
sx={{ fontWeight: 600, fontSize: 20 }}
> >
Lancer Lancer
</Button> </Button>
</div> </div>

View file

@ -1,59 +1,38 @@
// TeacherModeQuiz.tsx // TeacherModeQuiz.tsx
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import QuestionComponent from '../QuestionsDisplay/QuestionDisplay'; import QuestionComponent from '../QuestionsDisplay/QuestionDisplay';
import '../../pages/Student/JoinRoom/joinRoom.css'; import '../../pages/Student/JoinRoom/joinRoom.css';
import { QuestionType } from '../../Types/QuestionType'; import { QuestionType } from '../../Types/QuestionType';
// import { QuestionService } from '../../services/QuestionService';
import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton'; import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton';
import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@mui/material'; import { Dialog, DialogTitle, DialogContent, DialogActions, Button } from '@mui/material';
import { Question } from 'gift-pegjs'; import { Question } from 'gift-pegjs';
import { AnswerSubmissionToBackendType } from 'src/services/WebsocketService';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
// import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
interface TeacherModeQuizProps { interface TeacherModeQuizProps {
questionInfos: QuestionType; questionInfos: QuestionType;
answers: AnswerSubmissionToBackendType[]; submitAnswer: (answer: string | number | boolean, idQuestion: number) => void;
submitAnswer: (_answer: AnswerType, _idQuestion: number) => void;
disconnectWebSocket: () => void; disconnectWebSocket: () => void;
} }
const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({ const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
questionInfos, questionInfos,
answers,
submitAnswer, submitAnswer,
disconnectWebSocket disconnectWebSocket
}) => { }) => {
const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false); const [isAnswerSubmitted, setIsAnswerSubmitted] = useState(false);
const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false); const [isFeedbackDialogOpen, setIsFeedbackDialogOpen] = useState(false);
const [answer, setAnswer] = useState<AnswerType>(); const [feedbackMessage, setFeedbackMessage] = useState('');
// arrive here the first time after waiting for next question
useEffect(() => {
console.log(`TeacherModeQuiz: useEffect: answers: ${JSON.stringify(answers)}`);
console.log(`TeacherModeQuiz: useEffect: questionInfos.question.id: ${questionInfos.question.id} answer: ${answer}`);
const oldAnswer = answers[Number(questionInfos.question.id) -1 ]?.answer;
console.log(`TeacherModeQuiz: useEffect: oldAnswer: ${oldAnswer}`);
setAnswer(oldAnswer);
setIsFeedbackDialogOpen(false);
}, [questionInfos.question, answers]);
// handle showing the feedback dialog
useEffect(() => {
console.log(`TeacherModeQuiz: useEffect: answer: ${answer}`);
setIsAnswerSubmitted(answer !== undefined);
setIsFeedbackDialogOpen(answer !== undefined);
}, [answer]);
useEffect(() => { useEffect(() => {
console.log(`TeacherModeQuiz: useEffect: isAnswerSubmitted: ${isAnswerSubmitted}`); setIsAnswerSubmitted(false);
setIsFeedbackDialogOpen(isAnswerSubmitted); }, [questionInfos]);
}, [isAnswerSubmitted]);
const handleOnSubmitAnswer = (answer: AnswerType) => { const handleOnSubmitAnswer = (answer: string | number | boolean) => {
const idQuestion = Number(questionInfos.question.id) || -1; const idQuestion = Number(questionInfos.question.id) || -1;
submitAnswer(answer, idQuestion); submitAnswer(answer, idQuestion);
// setAnswer(answer); setFeedbackMessage(`Votre réponse est "${answer.toString()}".`);
setIsFeedbackDialogOpen(true); setIsFeedbackDialogOpen(true);
}; };
@ -64,21 +43,21 @@ const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
return ( return (
<div className='room'> <div className='room'>
<div className='roomHeader'> <div className='roomHeader'>
<DisconnectButton <DisconnectButton
onReturn={disconnectWebSocket} onReturn={disconnectWebSocket}
message={`Êtes-vous sûr de vouloir quitter?`} /> message={`Êtes-vous sûr de vouloir quitter?`} />
<div className='centerTitle'>
<div className='title'>Question {questionInfos.question.id}</div>
</div>
<div className='dumb'></div>
<div className='centerTitle'>
<div className='title'>Question {questionInfos.question.id}</div>
</div> </div>
<div className='dumb'></div> {isAnswerSubmitted ? (
</div>
{isAnswerSubmitted ? (
<div> <div>
En attente pour la prochaine question... En attente pour la prochaine question...
</div> </div>
@ -86,7 +65,6 @@ const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
<QuestionComponent <QuestionComponent
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
question={questionInfos.question as Question} question={questionInfos.question as Question}
answer={answer}
/> />
)} )}
@ -96,31 +74,20 @@ const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
> >
<DialogTitle>Rétroaction</DialogTitle> <DialogTitle>Rétroaction</DialogTitle>
<DialogContent> <DialogContent>
<div style={{ {feedbackMessage}
wordWrap: 'break-word', <QuestionComponent
whiteSpace: 'pre-wrap', handleOnSubmitAnswer={handleOnSubmitAnswer}
maxHeight: '400px', question={questionInfos.question as Question}
overflowY: 'auto', showAnswer={true}
}}>
<div style={{ textAlign: 'left', fontWeight: 'bold', marginTop: '10px' }}
>Question : </div>
</div>
<QuestionComponent
handleOnSubmitAnswer={handleOnSubmitAnswer}
question={questionInfos.question as Question}
showAnswer={true}
answer={answer}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleFeedbackDialogClose} color="primary"> <Button onClick={handleFeedbackDialogClose} color="primary">
Fermer OK
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
</div> </div>
); );
}; };

View file

@ -1,11 +1,11 @@
// constants.tsx // constants.tsx
const ENV_VARIABLES = { const ENV_VARIABLES = {
MODE: process.env.MODE || "production", MODE: 'production',
VITE_BACKEND_URL: process.env.VITE_BACKEND_URL || "", VITE_BACKEND_URL: import.meta.env.VITE_BACKEND_URL || "",
BACKEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.BACKEND_PORT}` : ''}` : process.env.VITE_BACKEND_URL || '', VITE_BACKEND_SOCKET_URL: import.meta.env.VITE_BACKEND_SOCKET_URL || "",
FRONTEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.PORT}` : ''}` : ''
}; };
console.log(`ENV_VARIABLES.VITE_BACKEND_URL=${ENV_VARIABLES.VITE_BACKEND_URL}`); console.log(`ENV_VARIABLES.VITE_BACKEND_URL=${ENV_VARIABLES.VITE_BACKEND_URL}`);
console.log(`ENV_VARIABLES.VITE_BACKEND_SOCKET_URL=${ENV_VARIABLES.VITE_BACKEND_SOCKET_URL}`);
export { ENV_VARIABLES }; export { ENV_VARIABLES };

View file

@ -1,61 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import './authDrawer.css';
import SimpleLogin from './providers/SimpleLogin/Login';
import authService from '../../services/AuthService';
import { ENV_VARIABLES } from '../../constants';
import ButtonAuth from './providers/OAuth-Oidc/ButtonAuth';
const AuthSelection: React.FC = () => {
const [authData, setAuthData] = useState<any>(null); // Stocke les données d'auth
const navigate = useNavigate();
ENV_VARIABLES.VITE_BACKEND_URL;
// Récupérer les données d'authentification depuis l'API
useEffect(() => {
const fetchData = async () => {
const data = await authService.fetchAuthData();
setAuthData(data);
};
fetchData();
}, []);
return (
<div className="auth-selection-page">
<h1>Connexion</h1>
{/* Formulaire de connexion Simple Login */}
{authData && authData['simpleauth'] && (
<div className="form-container">
<SimpleLogin />
</div>
)}
{/* Conteneur OAuth/OIDC */}
{authData && Object.keys(authData).some(key => authData[key].type === 'oidc' || authData[key].type === 'oauth') && (
<div className="auth-button-container">
{Object.keys(authData).map((providerKey) => {
const providerType = authData[providerKey].type;
if (providerType === 'oidc' || providerType === 'oauth') {
return (
<ButtonAuth
key={providerKey}
providerName={providerKey}
providerType={providerType}
/>
);
}
return null;
})}
</div>
)}
<div>
<button className="home-button-container" onClick={() => navigate('/')}>Retour à l'accueil</button>
</div>
</div>
);
};
export default AuthSelection;

View file

@ -1,49 +0,0 @@
.auth-selection-page {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
h1 {
margin-bottom: 20px;
}
.form-container {
border: 1px solid #ccc;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
width: 400px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
text-align: center;
}
form {
display: flex;
flex-direction: column;
}
input {
margin: 5px 0;
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
}
button {
padding: 10px;
border: none;
border-radius: 4px;
background-color: #5271ff;
color: white;
cursor: pointer;
}
/* This hover was affecting the entire App */
/* button:hover {
background-color: #5271ff;
} */
.home-button-container {
background: none;
color: black;
}
.home-button-container:hover {
background: none;
color: black;
text-decoration: underline;
}

View file

@ -1,27 +0,0 @@
import React from 'react';
import { useEffect } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import apiService from '../../../services/ApiService';
const OAuthCallback: React.FC = () => {
const navigate = useNavigate();
const location = useLocation();
useEffect(() => {
const searchParams = new URLSearchParams(location.search);
const user = searchParams.get('user');
const username = searchParams.get('username');
if (user) {
apiService.saveToken(user);
apiService.saveUsername(username || "");
navigate('/teacher/dashboard');
} else {
navigate('/login');
}
}, []);
return <div>Loading...</div>;
};
export default OAuthCallback;

View file

@ -1,27 +0,0 @@
import React from 'react';
import { ENV_VARIABLES } from '../../../../constants';
import '../css/buttonAuth.css';
interface ButtonAuthContainerProps {
providerName: string;
providerType: 'oauth' | 'oidc';
}
const handleAuthLogin = (provider: string) => {
window.location.href = `${ENV_VARIABLES.BACKEND_URL}/api/auth/${provider}`;
};
const ButtonAuth: React.FC<ButtonAuthContainerProps> = ({ providerName, providerType }) => {
return (
<>
<div className={`${providerName}-${providerType}-container button-container`}>
<h2>Se connecter avec {providerType.toUpperCase()}</h2>
<button key={providerName} className={`provider-btn ${providerType}-btn`} onClick={() => handleAuthLogin(providerName)}>
Continuer avec {providerName}
</button>
</div>
</>
);
};
export default ButtonAuth;

View file

@ -1,114 +0,0 @@
// JoinRoom.tsx
import React, { useEffect, useState } from 'react';
import { TextField, FormLabel, RadioGroup, FormControlLabel, Radio, Box } from '@mui/material';
import LoadingButton from '@mui/lab/LoadingButton';
import LoginContainer from '../../../../components/LoginContainer/LoginContainer';
import ApiService from '../../../../services/ApiService';
const Register: React.FC = () => {
const [name, setName] = useState(''); // State for name
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [roles, setRoles] = useState<string[]>(['teacher']); // Set 'student' as the default role
const [connectionError, setConnectionError] = useState<string>('');
const [isConnecting] = useState<boolean>(false);
useEffect(() => {
return () => { };
}, []);
const handleRoleChange = (role: string) => {
setRoles([role]); // Update the roles array to contain the selected role
};
const isValidEmail = (email: string) => {
// Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
const register = async () => {
if (!isValidEmail(email)) {
setConnectionError("Veuillez entrer une adresse email valide.");
return;
}
const result = await ApiService.register(name, email, password, roles);
if (result !== true) {
setConnectionError(result);
return;
}
};
return (
<LoginContainer
title="Créer un compte"
error={connectionError}
>
<TextField
label="Nom"
variant="outlined"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Votre nom"
sx={{ marginBottom: '1rem' }}
fullWidth
/>
<TextField
label="Email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse courriel"
sx={{ marginBottom: '1rem' }}
fullWidth
type="email"
error={!!connectionError && !isValidEmail(email)}
helperText={connectionError && !isValidEmail(email) ? "Adresse email invalide." : ""}
/>
<TextField
label="Mot de passe"
variant="outlined"
value={password}
type="password"
onChange={(e) => setPassword(e.target.value)}
placeholder="Mot de passe"
sx={{ marginBottom: '1rem' }}
fullWidth
/>
<Box sx={{ display: 'flex', alignItems: 'center', marginBottom: '1rem' }}>
<FormLabel component="legend" sx={{ marginRight: '1rem' }}>Choisir votre rôle</FormLabel>
<RadioGroup
row
aria-label="role"
name="role"
value={roles[0]}
onChange={(e) => handleRoleChange(e.target.value)}
>
<FormControlLabel value="student" control={<Radio />} label="Étudiant" />
<FormControlLabel value="teacher" control={<Radio />} label="Professeur" />
</RadioGroup>
</Box>
<LoadingButton
loading={isConnecting}
onClick={register}
variant="contained"
sx={{ marginBottom: `${connectionError && '2rem'}` }}
disabled={!name || !email || !password}
>
S'inscrire
</LoadingButton>
</LoginContainer>
);
};
export default Register;

View file

@ -1,68 +0,0 @@
import { useNavigate } from 'react-router-dom';
// JoinRoom.tsx
import React, { useEffect, useState } from 'react';
import { TextField } from '@mui/material';
import LoadingButton from '@mui/lab/LoadingButton';
import LoginContainer from '../../../../components/LoginContainer/LoginContainer'
import ApiService from '../../../../services/ApiService';
const ResetPassword: React.FC = () => {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [connectionError, setConnectionError] = useState<string>('');
const [isConnecting] = useState<boolean>(false);
useEffect(() => {
return () => {
};
}, []);
const reset = async () => {
const result = await ApiService.resetPassword(email);
if (!result) {
setConnectionError(result.toString());
return;
}
navigate("/login")
};
return (
<LoginContainer
title='Récupération du compte'
error={connectionError}>
<TextField
label="Email"
variant="outlined"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse courriel"
sx={{ marginBottom: '1rem' }}
fullWidth
/>
<LoadingButton
loading={isConnecting}
onClick={reset}
variant="contained"
sx={{ marginBottom: `${connectionError && '2rem'}` }}
disabled={!email}
>
Réinitialiser le mot de passe
</LoadingButton>
</LoginContainer>
);
};
export default ResetPassword;

View file

@ -1,23 +0,0 @@
.provider-btn {
background-color: #ffffff;
border: 1px solid #ccc;
color: black;
margin: 4px 0 4px 0;
}
.provider-btn:hover {
background-color: #dbdbdb;
border: 1px solid #ccc;
color: black;
margin: 4px 0 4px 0;
}
.button-container {
border: 1px solid #ccc;
border-radius: 8px;
padding: 15px;
margin: 10px 0;
width: 400px;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
text-align: center;
}

View file

@ -1,17 +0,0 @@
.login-links {
padding-top: 10px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.login-links a {
padding: 4px;
color: #333;
text-decoration: none;
}
.login-links a:hover {
text-decoration: underline;
}

View file

@ -61,25 +61,6 @@
align-items: end; align-items: end;
} }
.auth-selection-btn {
position: absolute;
top: 20px;
right: 20px;
}
.auth-btn {
padding: 10px 20px;
background-color: #5271ff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s ease;
}
.auth-btn:hover {
background-color: #5976fa;
}
@media only screen and (max-width: 768px) { @media only screen and (max-width: 768px) {
.btn-container { .btn-container {
flex-direction: column; flex-direction: column;

View file

@ -5,7 +5,7 @@ import { ENV_VARIABLES } from 'src/constants';
import StudentModeQuiz from 'src/components/StudentModeQuiz/StudentModeQuiz'; import StudentModeQuiz from 'src/components/StudentModeQuiz/StudentModeQuiz';
import TeacherModeQuiz from 'src/components/TeacherModeQuiz/TeacherModeQuiz'; import TeacherModeQuiz from 'src/components/TeacherModeQuiz/TeacherModeQuiz';
import webSocketService, {AnswerSubmissionToBackendType} from '../../../services/WebsocketService'; import webSocketService, { AnswerSubmissionToBackendType } from '../../../services/WebsocketService';
import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton'; import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton';
import './joinRoom.css'; import './joinRoom.css';
@ -13,35 +13,18 @@ import { QuestionType } from '../../../Types/QuestionType';
import { TextField } from '@mui/material'; import { TextField } from '@mui/material';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import LoginContainer from 'src/components/LoginContainer/LoginContainer'; import LoginContainer from 'src/components/LoginContainer/LoginContainer'
import ApiService from '../../../services/ApiService';
import { useSearchParams } from 'react-router-dom';
export type AnswerType = Array<string | number | boolean>;
const JoinRoom: React.FC = () => { const JoinRoom: React.FC = () => {
const [roomName, setRoomName] = useState(''); const [roomName, setRoomName] = useState('');
const [username, setUsername] = useState(ApiService.getUsername()); const [username, setUsername] = useState('');
const [socket, setSocket] = useState<Socket | null>(null); const [socket, setSocket] = useState<Socket | null>(null);
const [isWaitingForTeacher, setIsWaitingForTeacher] = useState(false); const [isWaitingForTeacher, setIsWaitingForTeacher] = useState(false);
const [question, setQuestion] = useState<QuestionType>(); const [question, setQuestion] = useState<QuestionType>();
const [quizMode, setQuizMode] = useState<string>(); const [quizMode, setQuizMode] = useState<string>();
const [questions, setQuestions] = useState<QuestionType[]>([]); const [questions, setQuestions] = useState<QuestionType[]>([]);
const [answers, setAnswers] = useState<AnswerSubmissionToBackendType[]>([]);
const [connectionError, setConnectionError] = useState<string>(''); const [connectionError, setConnectionError] = useState<string>('');
const [isConnecting, setIsConnecting] = useState<boolean>(false); const [isConnecting, setIsConnecting] = useState<boolean>(false);
const [isQRCodeJoin, setIsQRCodeJoin] = useState(false);
const [searchParams] = useSearchParams();
useEffect(() => {
const roomFromUrl = searchParams.get('roomName');
if (roomFromUrl) {
setRoomName(roomFromUrl);
setIsQRCodeJoin(true);
console.log('Mode QR Code détecté, salle:', roomFromUrl);
}
}, [searchParams]);
useEffect(() => { useEffect(() => {
handleCreateSocket(); handleCreateSocket();
@ -50,41 +33,23 @@ const JoinRoom: React.FC = () => {
}; };
}, []); }, []);
useEffect(() => {
console.log(`JoinRoom: useEffect: questions: ${JSON.stringify(questions)}`);
setAnswers(questions ? Array(questions.length).fill({} as AnswerSubmissionToBackendType) : []);
}, [questions]);
const handleCreateSocket = () => { const handleCreateSocket = () => {
console.log(`JoinRoom: handleCreateSocket: ${ENV_VARIABLES.VITE_BACKEND_URL}`); console.log(`JoinRoom: handleCreateSocket: ${ENV_VARIABLES.VITE_BACKEND_SOCKET_URL}`);
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
socket.on('join-success', (roomJoinedName) => { socket.on('join-success', () => {
setIsWaitingForTeacher(true); setIsWaitingForTeacher(true);
setIsConnecting(false); setIsConnecting(false);
console.log(`on(join-success): Successfully joined the room ${roomJoinedName}`); console.log('Successfully joined the room.');
}); });
socket.on('next-question', (question: QuestionType) => { socket.on('next-question', (question: QuestionType) => {
console.log('JoinRoom: on(next-question): Received next-question:', question);
setQuizMode('teacher'); setQuizMode('teacher');
setIsWaitingForTeacher(false); setIsWaitingForTeacher(false);
setQuestion(question); setQuestion(question);
}); });
socket.on('launch-teacher-mode', (questions: QuestionType[]) => {
console.log('on(launch-teacher-mode): Received launch-teacher-mode:', questions);
setQuizMode('teacher');
setIsWaitingForTeacher(true);
setQuestions([]); // clear out from last time (in case quiz is repeated)
setQuestions(questions);
// wait for next-question
});
socket.on('launch-student-mode', (questions: QuestionType[]) => { socket.on('launch-student-mode', (questions: QuestionType[]) => {
console.log('on(launch-student-mode): Received launch-student-mode:', questions);
setQuizMode('student'); setQuizMode('student');
setIsWaitingForTeacher(false); setIsWaitingForTeacher(false);
setQuestions([]); // clear out from last time (in case quiz is repeated)
setQuestions(questions); setQuestions(questions);
setQuestion(questions[0]); setQuestion(questions[0]);
}); });
@ -113,7 +78,6 @@ const JoinRoom: React.FC = () => {
}; };
const disconnect = () => { const disconnect = () => {
// localStorage.clear();
webSocketService.disconnect(); webSocketService.disconnect();
setSocket(null); setSocket(null);
setQuestion(undefined); setQuestion(undefined);
@ -132,35 +96,19 @@ const JoinRoom: React.FC = () => {
} }
if (username && roomName) { if (username && roomName) {
console.log(`Tentative de rejoindre : ${roomName}, utilisateur : ${username}`);
webSocketService.joinRoom(roomName, username); webSocketService.joinRoom(roomName, username);
} }
}; };
const handleOnSubmitAnswer = (answer: AnswerType, idQuestion: number) => { const handleOnSubmitAnswer = (answer: string | number | boolean, idQuestion: number) => {
console.info(`JoinRoom: handleOnSubmitAnswer: answer: ${answer}, idQuestion: ${idQuestion}`);
const answerData: AnswerSubmissionToBackendType = { const answerData: AnswerSubmissionToBackendType = {
roomName: roomName, roomName: roomName,
answer: answer, answer: answer,
username: username, username: username,
idQuestion: idQuestion idQuestion: idQuestion
}; };
// localStorage.setItem(`Answer${idQuestion}`, JSON.stringify(answer));
setAnswers((prevAnswers) => {
console.log(`JoinRoom: handleOnSubmitAnswer: prevAnswers: ${JSON.stringify(prevAnswers)}`);
const newAnswers = [...prevAnswers]; // Create a copy of the previous answers array
newAnswers[idQuestion - 1] = answerData; // Update the specific answer
return newAnswers; // Return the new array
});
console.log(`JoinRoom: handleOnSubmitAnswer: answers: ${JSON.stringify(answers)}`);
webSocketService.submitAnswer(answerData);
};
const handleReturnKey = (e: React.KeyboardEvent<HTMLInputElement>) => { webSocketService.submitAnswer(answerData);
if (e.key === 'Enter' && username && roomName) {
handleSocket();
}
}; };
if (isWaitingForTeacher) { if (isWaitingForTeacher) {
@ -191,7 +139,6 @@ const JoinRoom: React.FC = () => {
return ( return (
<StudentModeQuiz <StudentModeQuiz
questions={questions} questions={questions}
answers={answers}
submitAnswer={handleOnSubmitAnswer} submitAnswer={handleOnSubmitAnswer}
disconnectWebSocket={disconnect} disconnectWebSocket={disconnect}
/> />
@ -201,7 +148,6 @@ const JoinRoom: React.FC = () => {
question && ( question && (
<TeacherModeQuiz <TeacherModeQuiz
questionInfos={question} questionInfos={question}
answers={answers}
submitAnswer={handleOnSubmitAnswer} submitAnswer={handleOnSubmitAnswer}
disconnectWebSocket={disconnect} disconnectWebSocket={disconnect}
/> />
@ -210,25 +156,20 @@ const JoinRoom: React.FC = () => {
default: default:
return ( return (
<LoginContainer <LoginContainer
title={isQRCodeJoin ? `Rejoindre la salle ${roomName}` : 'Rejoindre une salle'} title='Rejoindre une salle'
error={connectionError} error={connectionError}>
>
{/* Afficher champ salle SEULEMENT si pas de QR code */} <TextField
{!isQRCodeJoin && ( type="number"
<TextField label="Numéro de la salle"
type="text" variant="outlined"
label="Nom de la salle" value={roomName}
variant="outlined" onChange={(e) => setRoomName(e.target.value)}
value={roomName} placeholder="Numéro de la salle"
onChange={(e) => setRoomName(e.target.value.toUpperCase())} sx={{ marginBottom: '1rem' }}
placeholder="Nom de la salle" fullWidth
sx={{ marginBottom: '1rem' }} />
fullWidth={true}
onKeyDown={handleReturnKey}
/>
)}
{/* Champ username toujours visible */}
<TextField <TextField
label="Nom d'utilisateur" label="Nom d'utilisateur"
variant="outlined" variant="outlined"
@ -236,8 +177,7 @@ const JoinRoom: React.FC = () => {
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
placeholder="Nom d'utilisateur" placeholder="Nom d'utilisateur"
sx={{ marginBottom: '1rem' }} sx={{ marginBottom: '1rem' }}
fullWidth={true} fullWidth
onKeyDown={handleReturnKey}
/> />
<LoadingButton <LoadingButton
@ -245,10 +185,9 @@ const JoinRoom: React.FC = () => {
onClick={handleSocket} onClick={handleSocket}
variant="contained" variant="contained"
sx={{ marginBottom: `${connectionError && '2rem'}` }} sx={{ marginBottom: `${connectionError && '2rem'}` }}
disabled={!username || (isQRCodeJoin && !roomName)} disabled={!username || !roomName}
> >Rejoindre</LoadingButton>
{isQRCodeJoin ? 'Rejoindre avec QR Code' : 'Rejoindre'}
</LoadingButton>
</LoginContainer> </LoginContainer>
); );
} }

View file

@ -12,13 +12,8 @@ import ApiService from '../../../services/ApiService';
import './dashboard.css'; import './dashboard.css';
import ImportModal from 'src/components/ImportModal/ImportModal'; import ImportModal from 'src/components/ImportModal/ImportModal';
//import axios from 'axios'; //import axios from 'axios';
import { RoomType } from 'src/Types/RoomType';
// import { useRooms } from '../ManageRoom/RoomContext';
import { import {
Dialog,
DialogActions,
DialogContent,
DialogTitle,
TextField, TextField,
IconButton, IconButton,
InputAdornment, InputAdornment,
@ -28,7 +23,6 @@ import {
NativeSelect, NativeSelect,
CardContent, CardContent,
styled, styled,
DialogContentText
} from '@mui/material'; } from '@mui/material';
import { import {
Search, Search,
@ -37,10 +31,11 @@ import {
Upload, Upload,
FolderCopy, FolderCopy,
ContentCopy, ContentCopy,
Edit Edit,
Share,
// DriveFileMove
} from '@mui/icons-material'; } from '@mui/icons-material';
import DownloadQuizModal from 'src/components/DownloadQuizModal/DownloadQuizModal'; import DownloadQuizModal from 'src/components/DownloadQuizModal/DownloadQuizModal';
import ShareQuizModal from 'src/components/ShareQuizModal/ShareQuizModal';
// Create a custom-styled Card component // Create a custom-styled Card component
const CustomCard = styled(Card)({ const CustomCard = styled(Card)({
@ -48,7 +43,7 @@ const CustomCard = styled(Card)({
position: 'relative', position: 'relative',
margin: '40px 0 20px 0', // Add top margin to make space for the tab margin: '40px 0 20px 0', // Add top margin to make space for the tab
borderRadius: '8px', borderRadius: '8px',
paddingTop: '20px' // Ensure content inside the card doesn't overlap with the tab paddingTop: '20px', // Ensure content inside the card doesn't overlap with the tab
}); });
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
@ -58,14 +53,6 @@ const Dashboard: React.FC = () => {
const [showImportModal, setShowImportModal] = useState<boolean>(false); const [showImportModal, setShowImportModal] = useState<boolean>(false);
const [folders, setFolders] = useState<FolderType[]>([]); const [folders, setFolders] = useState<FolderType[]>([]);
const [selectedFolderId, setSelectedFolderId] = useState<string>(''); // Selected folder const [selectedFolderId, setSelectedFolderId] = useState<string>(''); // Selected folder
const [rooms, setRooms] = useState<RoomType[]>([]);
const [openAddRoomDialog, setOpenAddRoomDialog] = useState(false);
const [newRoomTitle, setNewRoomTitle] = useState('');
// const { selectedRoom, selectRoom, createRoom } = useRooms();
const [selectedRoom, selectRoom] = useState<RoomType>(); // menu
const [errorMessage, setErrorMessage] = useState('');
const [showErrorDialog, setShowErrorDialog] = useState(false);
const [isSearchVisible, setIsSearchVisible] = useState(false);
// Filter quizzes based on search term // Filter quizzes based on search term
// const filteredQuizzes = quizzes.filter(quiz => // const filteredQuizzes = quizzes.filter(quiz =>
@ -78,6 +65,7 @@ const Dashboard: React.FC = () => {
); );
}, [quizzes, searchTerm]); }, [quizzes, searchTerm]);
// Group quizzes by folder // Group quizzes by folder
const quizzesByFolder = filteredQuizzes.reduce((acc, quiz) => { const quizzesByFolder = filteredQuizzes.reduce((acc, quiz) => {
if (!acc[quiz.folderName]) { if (!acc[quiz.folderName]) {
@ -89,83 +77,28 @@ const Dashboard: React.FC = () => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
const isLoggedIn = await ApiService.isLoggedIn(); if (!ApiService.isLoggedIn()) {
console.log(`Dashboard: isLoggedIn: ${isLoggedIn}`); navigate("/teacher/login");
if (!isLoggedIn) {
navigate('/teacher/login');
return; return;
} else { }
const userRooms = await ApiService.getUserRooms(); else {
setRooms(userRooms as RoomType[]);
const userFolders = await ApiService.getUserFolders(); const userFolders = await ApiService.getUserFolders();
setFolders(userFolders as FolderType[]); setFolders(userFolders as FolderType[]);
} }
}; };
fetchData(); fetchData();
}, []); }, []);
useEffect(() => {
if (rooms.length > 0 && !selectedRoom) {
selectRoom(rooms[rooms.length - 1]);
localStorage.setItem('selectedRoomId', rooms[rooms.length - 1]._id);
}
}, [rooms, selectedRoom]);
const handleSelectRoom = (event: React.ChangeEvent<HTMLSelectElement>) => {
if (event.target.value === 'add-room') {
setOpenAddRoomDialog(true);
} else {
selectRoomByName(event.target.value);
}
};
const toggleSearchVisibility = () => {
setIsSearchVisible(!isSearchVisible);
};
// Créer une salle
const createRoom = async (title: string) => {
// Créer la salle et récupérer l'objet complet
const newRoom = await ApiService.createRoom(title);
// Mettre à jour la liste des salles
const updatedRooms = await ApiService.getUserRooms();
setRooms(updatedRooms as RoomType[]);
// Sélectionner la nouvelle salle avec son ID
selectRoomByName(newRoom); // Utiliser l'ID de l'objet retourné
};
// Sélectionner une salle
const selectRoomByName = (roomId: string) => {
const room = rooms.find((r) => r._id === roomId);
selectRoom(room);
localStorage.setItem('selectedRoomId', roomId);
};
const handleCreateRoom = async () => {
if (newRoomTitle.trim()) {
try {
await createRoom(newRoomTitle);
const userRooms = await ApiService.getUserRooms();
setRooms(userRooms as RoomType[]);
setOpenAddRoomDialog(false);
setNewRoomTitle('');
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : 'Erreur inconnue');
setShowErrorDialog(true);
}
}
};
const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedFolderId(event.target.value); setSelectedFolderId(event.target.value);
}; };
useEffect(() => { useEffect(() => {
const fetchQuizzesForFolder = async () => { const fetchQuizzesForFolder = async () => {
if (selectedFolderId == '') { if (selectedFolderId == '') {
const folders = await ApiService.getUserFolders(); // HACK force user folders to load on first load const folders = await ApiService.getUserFolders(); // HACK force user folders to load on first load
//console.log("show all quizzes") //console.log("show all quizzes")
@ -176,29 +109,33 @@ const Dashboard: React.FC = () => {
//console.log("folder: ", folder.title, " quiz: ", folderQuizzes); //console.log("folder: ", folder.title, " quiz: ", folderQuizzes);
// add the folder.title to the QuizType if the folderQuizzes is an array // add the folder.title to the QuizType if the folderQuizzes is an array
addFolderTitleToQuizzes(folderQuizzes, folder.title); addFolderTitleToQuizzes(folderQuizzes, folder.title);
quizzes = quizzes.concat(folderQuizzes as QuizType[]); quizzes = quizzes.concat(folderQuizzes as QuizType[])
} }
setQuizzes(quizzes as QuizType[]); setQuizzes(quizzes as QuizType[]);
} else { }
console.log('show some quizzes'); else {
console.log("show some quizzes")
const folderQuizzes = await ApiService.getFolderContent(selectedFolderId); const folderQuizzes = await ApiService.getFolderContent(selectedFolderId);
console.log('folderQuizzes: ', folderQuizzes); console.log("folderQuizzes: ", folderQuizzes);
// get the folder title from its id // get the folder title from its id
const folderTitle = const folderTitle = folders.find((folder) => folder._id === selectedFolderId)?.title || '';
folders.find((folder) => folder._id === selectedFolderId)?.title || '';
addFolderTitleToQuizzes(folderQuizzes, folderTitle); addFolderTitleToQuizzes(folderQuizzes, folderTitle);
setQuizzes(folderQuizzes as QuizType[]); setQuizzes(folderQuizzes as QuizType[]);
} }
}; };
fetchQuizzesForFolder(); fetchQuizzesForFolder();
}, [selectedFolderId]); }, [selectedFolderId]);
const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => { const handleSearch = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchTerm(event.target.value); setSearchTerm(event.target.value);
}; };
const handleRemoveQuiz = async (quiz: QuizType) => { const handleRemoveQuiz = async (quiz: QuizType) => {
try { try {
const confirmed = window.confirm('Voulez-vous vraiment supprimer ce quiz?'); const confirmed = window.confirm('Voulez-vous vraiment supprimer ce quiz?');
@ -212,27 +149,30 @@ const Dashboard: React.FC = () => {
} }
}; };
const handleDuplicateQuiz = async (quiz: QuizType) => { const handleDuplicateQuiz = async (quiz: QuizType) => {
try { try {
await ApiService.duplicateQuiz(quiz._id); await ApiService.duplicateQuiz(quiz._id);
if (selectedFolderId == '') { if (selectedFolderId == '') {
const folders = await ApiService.getUserFolders(); // HACK force user folders to load on first load const folders = await ApiService.getUserFolders(); // HACK force user folders to load on first load
console.log('show all quizzes'); console.log("show all quizzes")
let quizzes: QuizType[] = []; let quizzes: QuizType[] = [];
for (const folder of folders as FolderType[]) { for (const folder of folders as FolderType[]) {
const folderQuizzes = await ApiService.getFolderContent(folder._id); const folderQuizzes = await ApiService.getFolderContent(folder._id);
console.log('folder: ', folder.title, ' quiz: ', folderQuizzes); console.log("folder: ", folder.title, " quiz: ", folderQuizzes);
addFolderTitleToQuizzes(folderQuizzes, folder.title); addFolderTitleToQuizzes(folderQuizzes, folder.title);
quizzes = quizzes.concat(folderQuizzes as QuizType[]); quizzes = quizzes.concat(folderQuizzes as QuizType[]);
} }
setQuizzes(quizzes as QuizType[]); setQuizzes(quizzes as QuizType[]);
} else { }
console.log('show some quizzes'); else {
console.log("show some quizzes")
const folderQuizzes = await ApiService.getFolderContent(selectedFolderId); const folderQuizzes = await ApiService.getFolderContent(selectedFolderId);
addFolderTitleToQuizzes(folderQuizzes, selectedFolderId); addFolderTitleToQuizzes(folderQuizzes, selectedFolderId);
setQuizzes(folderQuizzes as QuizType[]); setQuizzes(folderQuizzes as QuizType[]);
} }
} catch (error) { } catch (error) {
console.error('Error duplicating quiz:', error); console.error('Error duplicating quiz:', error);
@ -241,6 +181,7 @@ const Dashboard: React.FC = () => {
const handleOnImport = () => { const handleOnImport = () => {
setShowImportModal(true); setShowImportModal(true);
}; };
const validateQuiz = (questions: string[]) => { const validateQuiz = (questions: string[]) => {
@ -252,10 +193,11 @@ const Dashboard: React.FC = () => {
// Otherwise the quiz is invalid // Otherwise the quiz is invalid
for (let i = 0; i < questions.length; i++) { for (let i = 0; i < questions.length; i++) {
try { try {
// questions[i] = QuestionService.ignoreImgTags(questions[i]);
const parsedItem = parse(questions[i]); const parsedItem = parse(questions[i]);
Template(parsedItem[0]); Template(parsedItem[0]);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) { } catch (error) {
console.error('Error parsing question:', error);
return false; return false;
} }
} }
@ -272,6 +214,7 @@ const Dashboard: React.FC = () => {
setFolders(userFolders as FolderType[]); setFolders(userFolders as FolderType[]);
const newlyCreatedFolder = userFolders[userFolders.length - 1] as FolderType; const newlyCreatedFolder = userFolders[userFolders.length - 1] as FolderType;
setSelectedFolderId(newlyCreatedFolder._id); setSelectedFolderId(newlyCreatedFolder._id);
} }
} catch (error) { } catch (error) {
console.error('Error creating folder:', error); console.error('Error creating folder:', error);
@ -279,6 +222,7 @@ const Dashboard: React.FC = () => {
}; };
const handleDeleteFolder = async () => { const handleDeleteFolder = async () => {
try { try {
const confirmed = window.confirm('Voulez-vous vraiment supprimer ce dossier?'); const confirmed = window.confirm('Voulez-vous vraiment supprimer ce dossier?');
if (confirmed) { if (confirmed) {
@ -288,17 +232,18 @@ const Dashboard: React.FC = () => {
} }
const folders = await ApiService.getUserFolders(); // HACK force user folders to load on first load const folders = await ApiService.getUserFolders(); // HACK force user folders to load on first load
console.log('show all quizzes'); console.log("show all quizzes")
let quizzes: QuizType[] = []; let quizzes: QuizType[] = [];
for (const folder of folders as FolderType[]) { for (const folder of folders as FolderType[]) {
const folderQuizzes = await ApiService.getFolderContent(folder._id); const folderQuizzes = await ApiService.getFolderContent(folder._id);
console.log('folder: ', folder.title, ' quiz: ', folderQuizzes); console.log("folder: ", folder.title, " quiz: ", folderQuizzes);
quizzes = quizzes.concat(folderQuizzes as QuizType[]); quizzes = quizzes.concat(folderQuizzes as QuizType[])
} }
setQuizzes(quizzes as QuizType[]); setQuizzes(quizzes as QuizType[]);
setSelectedFolderId(''); setSelectedFolderId('');
} catch (error) { } catch (error) {
console.error('Error deleting folder:', error); console.error('Error deleting folder:', error);
} }
@ -308,10 +253,7 @@ const Dashboard: React.FC = () => {
try { try {
// folderId: string GET THIS FROM CURRENT FOLDER // folderId: string GET THIS FROM CURRENT FOLDER
// currentTitle: string GET THIS FROM CURRENT FOLDER // currentTitle: string GET THIS FROM CURRENT FOLDER
const newTitle = prompt( const newTitle = prompt('Entrée le nouveau nom du fichier', folders.find((folder) => folder._id === selectedFolderId)?.title);
'Entrée le nouveau nom du fichier',
folders.find((folder) => folder._id === selectedFolderId)?.title
);
if (newTitle) { if (newTitle) {
const renamedFolderId = selectedFolderId; const renamedFolderId = selectedFolderId;
const result = await ApiService.renameFolder(selectedFolderId, newTitle); const result = await ApiService.renameFolder(selectedFolderId, newTitle);
@ -348,296 +290,152 @@ const Dashboard: React.FC = () => {
}; };
const handleCreateQuiz = () => { const handleCreateQuiz = () => {
navigate('/teacher/editor-quiz/new'); navigate("/teacher/editor-quiz/new");
}; }
const handleEditQuiz = (quiz: QuizType) => { const handleEditQuiz = (quiz: QuizType) => {
navigate(`/teacher/editor-quiz/${quiz._id}`); navigate(`/teacher/editor-quiz/${quiz._id}`);
}; }
const handleLancerQuiz = (quiz: QuizType) => { const handleLancerQuiz = (quiz: QuizType) => {
if (selectedRoom) { navigate(`/teacher/manage-room/${quiz._id}`);
navigate(`/teacher/manage-room/${quiz._id}/${selectedRoom.title}`); }
} else {
const randomSixDigit = Math.floor(100000 + Math.random() * 900000); const handleShareQuiz = async (quiz: QuizType) => {
navigate(`/teacher/manage-room/${quiz._id}/${randomSixDigit}`); try {
const email = prompt(`Veuillez saisir l'email de la personne avec qui vous souhaitez partager ce quiz`, "");
if (email) {
const result = await ApiService.ShareQuiz(quiz._id, email);
if (!result) {
window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`)
return;
}
window.alert(`Quiz partagé avec succès!`)
}
} catch (error) {
console.error('Erreur lors du partage du quiz:', error);
} }
}; }
return ( return (
<div className="dashboard">
{/* Conteneur pour le titre et le sélecteur de salle */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}
>
{/* Titre tableau de bord */}
<div className="title" style={{ fontSize: '30px', fontWeight: 'bold' }}>
Tableau de bord
</div>
{/* Sélecteur de salle */} <div className="dashboard">
<div
className="roomSelection" <div className="title">Tableau de bord</div>
style={{ display: 'flex', justifyContent: 'flex-end', gap: '15px' }}
> <div className="search-bar">
<select <TextField
value={selectedRoom?._id || ''} onChange={handleSearch}
onChange={(e) => handleSelectRoom(e)} value={searchTerm}
id="room-select" placeholder="Rechercher un quiz par son titre"
style={{ fullWidth
padding: '8px 12px', InputProps={{
fontSize: '14px', endAdornment: (
borderRadius: '8px', <InputAdornment position="end">
border: '1px solid #ccc', <IconButton>
backgroundColor: '#fff', <Search />
maxWidth: '200px', </IconButton>
cursor: 'pointer', </InputAdornment>
fontWeight: '500' )
}} }}
> />
<option value="" disabled>
Sélectionner une salle
</option>
{rooms.map((room) => (
<option key={room._id} value={room._id}>
{room.title}
</option>
))}
<option
value="add-room"
style={{
color: 'black',
backgroundColor: '#f0f0f0',
fontWeight: 'bold'
}}
>
Ajouter une salle
</option>
</select>
</div>
</div> </div>
{/* Dialog pour créer une salle */} <div className='folder'>
<Dialog open={openAddRoomDialog} onClose={() => setOpenAddRoomDialog(false)}> <div className='select'>
<DialogTitle>Créer une nouvelle salle</DialogTitle>
<DialogContent>
<TextField
value={newRoomTitle}
onChange={(e) => setNewRoomTitle(e.target.value.toUpperCase())}
fullWidth
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setOpenAddRoomDialog(false)}>Annuler</Button>
<Button onClick={handleCreateRoom}>Créer</Button>
</DialogActions>
</Dialog>
<Dialog open={showErrorDialog} onClose={() => setShowErrorDialog(false)}>
<DialogTitle>Erreur</DialogTitle>
<DialogContent>
<DialogContentText>{errorMessage}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowErrorDialog(false)}>Fermer</Button>
</DialogActions>
</Dialog>
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
alignItems: 'center',
width: '100%',
gap: '20px'
}}
></div>
{/* Conteneur principal avec les actions et la liste des quiz */}
<div className="folder">
<div className="select">
<NativeSelect <NativeSelect
id="select-folder" id="select-folder"
color="primary" color="primary"
value={selectedFolderId} value={selectedFolderId}
onChange={handleSelectFolder} onChange={handleSelectFolder}
sx={{
padding: '6px 12px',
maxWidth: '180px',
borderRadius: '8px',
borderColor: '#e0e0e0',
'&:hover': { borderColor: '#5271FF' }
}}
> >
<option value="">Tous les dossiers...</option> <option value=""> Tous les dossiers... </option>
{folders.map((folder) => (
<option value={folder._id} key={folder._id}> {folders.map((folder: FolderType) => (
{folder.title} <option value={folder._id} key={folder._id}> {folder.title} </option>
</option>
))} ))}
</NativeSelect> </NativeSelect>
</div> </div>
<div className="actions"> <div className='actions'>
<Tooltip title="Ajouter dossier" placement="top"> <Tooltip title="Ajouter dossier" placement="top">
<IconButton color="primary" onClick={handleCreateFolder}> <IconButton
{' '} color="primary"
<Add />{' '} onClick={handleCreateFolder}
</IconButton> > <Add /> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Renommer dossier" placement="top"> <Tooltip title="Renommer dossier" placement="top">
<div> <IconButton
<IconButton color="primary"
color="primary" onClick={handleRenameFolder}
onClick={handleRenameFolder} disabled={selectedFolderId == ''} // cannot action on all
disabled={selectedFolderId == ''} // cannot action on all > <Edit /> </IconButton>
>
{' '}
<Edit />{' '}
</IconButton>
</div>
</Tooltip> </Tooltip>
<Tooltip title="Dupliquer dossier" placement="top"> <Tooltip title="Dupliquer dossier" placement="top">
<div> <IconButton
<IconButton color="primary"
color="primary" onClick={handleDuplicateFolder}
onClick={handleDuplicateFolder} disabled={selectedFolderId == ''} // cannot action on all
disabled={selectedFolderId == ''} // cannot action on all > <FolderCopy /> </IconButton>
>
{' '}
<FolderCopy />{' '}
</IconButton>
</div>
</Tooltip> </Tooltip>
<Tooltip title="Supprimer dossier" placement="top"> <Tooltip title="Supprimer dossier" placement="top">
<div> <IconButton
<IconButton aria-label="delete"
aria-label="delete" color="primary"
color="primary" onClick={handleDeleteFolder}
onClick={handleDeleteFolder} disabled={selectedFolderId == ''} // cannot action on all
disabled={selectedFolderId == ''} // cannot action on all > <DeleteOutline /> </IconButton>
>
{' '}
<DeleteOutline />{' '}
</IconButton>
</div>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div <div className='ajouter'>
className="search-bar" <Button
style={{ variant="outlined"
display: 'flex', color="primary"
justifyContent: 'space-between', startIcon={<Add />}
alignItems: 'center', onClick={handleCreateQuiz}
gap: '20px', >
width: '100%' Ajouter un nouveau quiz
}} </Button>
>
<div style={{ flex: 1 }}>
{!isSearchVisible ? (
<IconButton
onClick={toggleSearchVisibility}
sx={{
borderRadius: '8px',
border: '1px solid #ccc',
padding: '8px 12px',
backgroundColor: '#fff',
color: '#5271FF'
}}
>
<Search />
</IconButton>
) : (
<TextField
onChange={handleSearch}
value={searchTerm}
placeholder="Rechercher un quiz"
fullWidth
autoFocus
sx={{
borderRadius: '8px',
border: '1px solid #ccc',
padding: '8px 12px',
backgroundColor: '#fff',
fontWeight: 500,
width: '100%',
maxWidth: '1000px'
}}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={toggleSearchVisibility}
sx={{
borderRadius: '8px',
border: '1px solid #ccc',
backgroundColor: '#fff',
color: '#5271FF'
}}
>
<Search />
</IconButton>
</InputAdornment>
)
}}
/>
)}
</div>
{/* À droite : les boutons */} <Button
<div style={{ display: 'flex', gap: '12px' }}> variant="outlined"
<Button color="primary"
variant="outlined" startIcon={<Upload />}
color="primary" onClick={handleOnImport}
startIcon={<Add />} >
onClick={handleCreateQuiz} Import
sx={{ borderRadius: '8px', minWidth: 'auto', padding: '4px 12px' }} </Button>
>
Nouveau quiz
</Button>
<Button
variant="outlined"
color="primary"
startIcon={<Upload />}
onClick={handleOnImport}
>
Importer
</Button>
</div>
</div> </div>
<div className='list'>
<div className="list"> {Object.keys(quizzesByFolder).map(folderName => (
{Object.keys(quizzesByFolder).map((folderName) => ( <CustomCard key={folderName} className='folder-card'>
<CustomCard key={folderName} className="folder-card"> <div className='folder-tab'>{folderName}</div>
<div className="folder-tab">{folderName}</div>
<CardContent> <CardContent>
{quizzesByFolder[folderName].map((quiz: QuizType) => ( {quizzesByFolder[folderName].map((quiz: QuizType) => (
<div className="quiz" key={quiz._id}> <div className='quiz' key={quiz._id}>
<div className="title"> <div className='title'>
<Tooltip title="Démarrer" placement="top"> <Tooltip title="Lancer quiz" placement="top">
<div> <Button
<Button variant="outlined"
variant="outlined" onClick={() => handleLancerQuiz(quiz)}
onClick={() => handleLancerQuiz(quiz)} disabled={!validateQuiz(quiz.content)}
disabled={!validateQuiz(quiz.content)} >
> {`${quiz.title} (${quiz.content.length} question${quiz.content.length > 1 ? 's' : ''})`}
{`${quiz.title} (${ </Button>
quiz.content.length
} question${
quiz.content.length > 1 ? 's' : ''
})`}
</Button>
</div>
</Tooltip> </Tooltip>
</div> </div>
@ -646,38 +444,33 @@ const Dashboard: React.FC = () => {
<DownloadQuizModal quiz={quiz} /> <DownloadQuizModal quiz={quiz} />
</div> </div>
<Tooltip title="Modifier" placement="top"> <Tooltip title="Modifier quiz" placement="top">
<IconButton <IconButton
color="primary" color="primary"
onClick={() => handleEditQuiz(quiz)} onClick={() => handleEditQuiz(quiz)}
> > <Edit /> </IconButton>
{' '}
<Edit />{' '}
</IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Dupliquer" placement="top"> <Tooltip title="Dupliquer quiz" placement="top">
<IconButton <IconButton
color="primary" color="primary"
onClick={() => handleDuplicateQuiz(quiz)} onClick={() => handleDuplicateQuiz(quiz)}
> > <ContentCopy /> </IconButton>
{' '}
<ContentCopy />{' '}
</IconButton>
</Tooltip> </Tooltip>
<div className="quiz-share">
<ShareQuizModal quiz={quiz} />
</div>
<Tooltip title="Supprimer" placement="top"> <Tooltip title="Supprimer quiz" placement="top">
<IconButton <IconButton
aria-label="delete" aria-label="delete"
color="error" color="primary"
onClick={() => handleRemoveQuiz(quiz)} onClick={() => handleRemoveQuiz(quiz)}
> > <DeleteOutline /> </IconButton>
{' '} </Tooltip>
<DeleteOutline />{' '}
</IconButton> <Tooltip title="Partager quiz" placement="top">
<IconButton
color="primary"
onClick={() => handleShareQuiz(quiz)}
> <Share /> </IconButton>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@ -692,6 +485,7 @@ const Dashboard: React.FC = () => {
handleOnImport={handleOnImport} handleOnImport={handleOnImport}
selectedFolder={selectedFolderId} selectedFolder={selectedFolderId}
/> />
</div> </div>
); );
}; };
@ -704,3 +498,4 @@ function addFolderTitleToQuizzes(folderQuizzes: string | QuizType[], folderName:
console.log(`quiz: ${quiz.title} folder: ${quiz.folderName}`); console.log(`quiz: ${quiz.title} folder: ${quiz.folderName}`);
}); });
} }

View file

@ -1,5 +1,5 @@
// EditorQuiz.tsx // EditorQuiz.tsx
import React, { useState, useEffect, CSSProperties } from 'react'; import React, { useState, useEffect, useRef, CSSProperties } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { FolderType } from '../../../Types/FolderType'; import { FolderType } from '../../../Types/FolderType';
@ -9,15 +9,14 @@ import GiftCheatSheet from 'src/components/GIFTCheatSheet/GiftCheatSheet';
import GIFTTemplatePreview from 'src/components/GiftTemplate/GIFTTemplatePreview'; import GIFTTemplatePreview from 'src/components/GiftTemplate/GIFTTemplatePreview';
import { QuizType } from '../../../Types/QuizType'; import { QuizType } from '../../../Types/QuizType';
import SaveIcon from '@mui/icons-material/Save';
import './editorQuiz.css'; import './editorQuiz.css';
import { Button, TextField, NativeSelect, Divider } from '@mui/material'; import { Button, TextField, NativeSelect, Divider, Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material';
import ReturnButton from 'src/components/ReturnButton/ReturnButton'; import ReturnButton from 'src/components/ReturnButton/ReturnButton';
import ImageGalleryModal from 'src/components/ImageGallery/ImageGalleryModal/ImageGalleryModal';
import ApiService from '../../../services/ApiService'; import ApiService from '../../../services/ApiService';
import { escapeForGIFT } from '../../../utils/giftUtils'; import { escapeForGIFT } from '../../../utils/giftUtils';
import { ENV_VARIABLES } from 'src/constants'; import { Upload } from '@mui/icons-material';
interface EditQuizParams { interface EditQuizParams {
id: string; id: string;
@ -39,6 +38,8 @@ const QuizForm: React.FC = () => {
const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedFolder(event.target.value); setSelectedFolder(event.target.value);
}; };
const fileInputRef = useRef<HTMLInputElement>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [showScrollButton, setShowScrollButton] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false);
const scrollToTop = () => { const scrollToTop = () => {
@ -117,11 +118,7 @@ const QuizForm: React.FC = () => {
setValue(value); setValue(value);
} }
// split value when there is at least one blank line const linesArray = value.split(/(?<=^|[^\\]}.*)[\n]+/);
const linesArray = value.split(/\n{2,}/);
// if the first item in linesArray is blank, remove it
if (linesArray[0] === '') linesArray.shift();
if (linesArray[linesArray.length - 1] === '') linesArray.pop(); if (linesArray[linesArray.length - 1] === '') linesArray.pop();
@ -165,75 +162,87 @@ const QuizForm: React.FC = () => {
return <div>Chargement...</div>; return <div>Chargement...</div>;
} }
const handleSaveImage = async () => {
try {
const inputElement = document.getElementById('file-input') as HTMLInputElement;
if (!inputElement?.files || inputElement.files.length === 0) {
setDialogOpen(true);
return;
}
if (!inputElement.files || inputElement.files.length === 0) {
window.alert("Veuillez d'abord choisir une image à téléverser.")
return;
}
const imageUrl = await ApiService.uploadImage(inputElement.files[0]);
// Check for errors
if(imageUrl.indexOf("ERROR") >= 0) {
window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`)
return;
}
setImageLinks(prevLinks => [...prevLinks, imageUrl]);
// Reset the file input element
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`)
}
};
const handleCopyToClipboard = async (link: string) => { const handleCopyToClipboard = async (link: string) => {
navigator.clipboard.writeText(link); navigator.clipboard.writeText(link);
} }
const handleCopyImage = (id: string) => {
const escLink = `${ENV_VARIABLES.BACKEND_URL}/api/image/get/${id}`;
setImageLinks(prevLinks => [...prevLinks, escLink]);
}
return ( return (
<div className="quizEditor"> <div className='quizEditor'>
<div
className="editHeader" <div className='editHeader'>
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '32px'
}}
>
<ReturnButton <ReturnButton
askConfirm askConfirm
message={`Êtes-vous sûr de vouloir quitter l'éditeur sans sauvegarder le questionnaire?`} message={`Êtes-vous sûr de vouloir quitter l'éditeur sans sauvegarder le questionnaire?`}
/> />
<Button <div className='title'>Éditeur de quiz</div>
variant="contained"
onClick={handleQuizSave}
sx={{ display: 'flex', alignItems: 'center' }}
>
<SaveIcon sx={{ fontSize: 20 }} />
Enregistrer
</Button>
</div>
<div style={{ textAlign: 'center', marginTop: '30px' }}> <div className='dumb'></div>
<div className="title">Éditeur de quiz</div>
</div> </div>
{/* <h2 className="subtitle">Éditeur</h2> */} {/* <h2 className="subtitle">Éditeur</h2> */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}> <TextField
<TextField onChange={handleQuizTitleChange}
onChange={handleQuizTitleChange} value={quizTitle}
value={quizTitle} placeholder="Titre du quiz"
color="primary" label="Titre du quiz"
placeholder="Titre du quiz" fullWidth
label="Titre du quiz" />
sx={{ width: '200px', marginTop: '50px' }} <label>Choisir un dossier:
/> <NativeSelect
<NativeSelect id="select-folder"
id="select-folder" color="primary"
color="primary" value={selectedFolder}
value={selectedFolder} onChange={handleSelectFolder}
onChange={handleSelectFolder} disabled={!isNewQuiz}
disabled={!isNewQuiz} style={{ marginBottom: '16px' }} // Ajout de marge en bas
style={{ marginBottom: '16px', width: '200px', marginTop: '10px' }}
> >
<option disabled value=""> <option disabled value=""> Choisir un dossier... </option>
Choisir un dossier...
</option>
{folders.map((folder: FolderType) => (
<option value={folder._id} key={folder._id}>
{folder.title}
</option>
))}
</NativeSelect>
</div>
{folders.map((folder: FolderType) => (
<option value={folder._id} key={folder._id}> {folder.title} </option>
))}
</NativeSelect></label>
<Button variant="contained" onClick={handleQuizSave}>
Enregistrer
</Button>
<Divider style={{ margin: '16px 0' }} /> <Divider style={{ margin: '16px 0' }} />
@ -246,11 +255,37 @@ const QuizForm: React.FC = () => {
onEditorChange={handleUpdatePreview} /> onEditorChange={handleUpdatePreview} />
<div className='images'> <div className='images'>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}> <div className='upload'>
<h4>Mes images :</h4> <label className="dropArea">
<ImageGalleryModal handleCopy={handleCopyImage} /> <input type="file" id="file-input" className="file-input"
accept="image/jpeg, image/png"
multiple
ref={fileInputRef} />
<Button
variant="outlined"
aria-label='Téléverser'
onClick={handleSaveImage}>
Téléverser <Upload />
</Button>
</label>
<Dialog
open={dialogOpen}
onClose={() => setDialogOpen(false)} >
<DialogTitle>Erreur</DialogTitle>
<DialogContent>
Veuillez d&apos;abord choisir une image à téléverser.
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)} color="primary">
OK
</Button>
</DialogActions>
</Dialog>
</div> </div>
<h4>Mes images :</h4>
<div> <div>
<div> <div>
<div style={{ display: "inline" }}>(Voir section </div> <div style={{ display: "inline" }}>(Voir section </div>
@ -264,7 +299,7 @@ const QuizForm: React.FC = () => {
</div> </div>
<ul> <ul>
{imageLinks.map((link, index) => { {imageLinks.map((link, index) => {
const imgTag = `[markdown]![alt_text](${escapeForGIFT(link)} "texte de l'infobulle") {T}`; const imgTag = `![alt_text](${escapeForGIFT(link)} "texte de l'infobulle")`;
return ( return (
<li key={index}> <li key={index}>
<code <code

View file

@ -1,4 +1,6 @@
import { Link, useNavigate } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
// JoinRoom.tsx
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import './Login.css'; import './Login.css';
@ -36,11 +38,6 @@ const Login: React.FC = () => {
}; };
const handleReturnKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && email && password) {
login();
}
};
return ( return (
<LoginContainer <LoginContainer
@ -54,8 +51,7 @@ const Login: React.FC = () => {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse courriel" placeholder="Adresse courriel"
sx={{ marginBottom: '1rem' }} sx={{ marginBottom: '1rem' }}
fullWidth={true} fullWidth
onKeyDown={handleReturnKey} // Add this line as well
/> />
<TextField <TextField
@ -66,8 +62,7 @@ const Login: React.FC = () => {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Mot de passe" placeholder="Mot de passe"
sx={{ marginBottom: '1rem' }} sx={{ marginBottom: '1rem' }}
fullWidth={true} fullWidth
onKeyDown={handleReturnKey} // Add this line as well
/> />
<LoadingButton <LoadingButton

View file

@ -1,150 +1,69 @@
// ManageRoom.tsx
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { Socket } from 'socket.io-client'; import { Socket } from 'socket.io-client';
import { BaseQuestion, parse, Question } from 'gift-pegjs'; import { ParsedGIFTQuestion, BaseQuestion, parse, Question } from 'gift-pegjs';
import { isSimpleNumericalAnswer, isRangeNumericalAnswer, isHighLowNumericalAnswer } from "gift-pegjs/typeGuards";
import LiveResultsComponent from 'src/components/LiveResults/LiveResults'; import LiveResultsComponent from 'src/components/LiveResults/LiveResults';
import webSocketService, { // import { QuestionService } from '../../../services/QuestionService';
AnswerReceptionFromBackendType import webSocketService, { AnswerReceptionFromBackendType } from '../../../services/WebsocketService';
} from '../../../services/WebsocketService';
import { QuizType } from '../../../Types/QuizType'; import { QuizType } from '../../../Types/QuizType';
import GroupIcon from '@mui/icons-material/Group';
import './manageRoom.css'; import './manageRoom.css';
import QRCodeIcon from '@mui/icons-material/QrCode';
import { ENV_VARIABLES } from 'src/constants'; import { ENV_VARIABLES } from 'src/constants';
import { StudentType, Answer } from '../../../Types/StudentType'; import { StudentType, Answer } from '../../../Types/StudentType';
import { Button } from '@mui/material';
import LoadingCircle from 'src/components/LoadingCircle/LoadingCircle'; import LoadingCircle from 'src/components/LoadingCircle/LoadingCircle';
import { Refresh, Error } from '@mui/icons-material'; import { Refresh, Error } from '@mui/icons-material';
import StudentWaitPage from 'src/components/StudentWaitPage/StudentWaitPage'; import StudentWaitPage from 'src/components/StudentWaitPage/StudentWaitPage';
import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton'; import DisconnectButton from 'src/components/DisconnectButton/DisconnectButton';
//import QuestionNavigation from 'src/components/QuestionNavigation/QuestionNavigation';
import QuestionDisplay from 'src/components/QuestionsDisplay/QuestionDisplay'; import QuestionDisplay from 'src/components/QuestionsDisplay/QuestionDisplay';
import ApiService from '../../../services/ApiService'; import ApiService from '../../../services/ApiService';
import { QuestionType } from 'src/Types/QuestionType'; import { QuestionType } from 'src/Types/QuestionType';
import {
Button,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions
} from '@mui/material';
import { checkIfIsCorrect } from './useRooms';
import { QRCodeCanvas } from 'qrcode.react';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
const ManageRoom: React.FC = () => { const ManageRoom: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [roomName, setRoomName] = useState<string>('');
const [socket, setSocket] = useState<Socket | null>(null); const [socket, setSocket] = useState<Socket | null>(null);
const [students, setStudents] = useState<StudentType[]>([]); const [students, setStudents] = useState<StudentType[]>([]);
const { quizId = '', roomName = '' } = useParams<{ quizId: string; roomName: string }>(); const quizId = useParams<{ id: string }>();
const [quizQuestions, setQuizQuestions] = useState<QuestionType[] | undefined>(); const [quizQuestions, setQuizQuestions] = useState<QuestionType[] | undefined>();
const [quiz, setQuiz] = useState<QuizType | null>(null); const [quiz, setQuiz] = useState<QuizType | null>(null);
const [quizMode, setQuizMode] = useState<'teacher' | 'student'>('teacher'); const [quizMode, setQuizMode] = useState<'teacher' | 'student'>('teacher');
const [connectingError, setConnectingError] = useState<string>(''); const [connectingError, setConnectingError] = useState<string>('');
const [currentQuestion, setCurrentQuestion] = useState<QuestionType | undefined>(undefined); const [currentQuestion, setCurrentQuestion] = useState<QuestionType | undefined>(undefined);
const [quizStarted, setQuizStarted] = useState<boolean>(false);
const [formattedRoomName, setFormattedRoomName] = useState('');
const [newlyConnectedUser, setNewlyConnectedUser] = useState<StudentType | null>(null);
const roomUrl = `${window.location.origin}/student/join-room?roomName=${roomName}`;
const [showQrModal, setShowQrModal] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(roomUrl).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
// Handle the newly connected user in useEffect, because it needs state info
// not available in the socket.on() callback
useEffect(() => {
if (newlyConnectedUser) {
console.log(`Handling newly connected user: ${newlyConnectedUser.name}`);
setStudents((prevStudents) => [...prevStudents, newlyConnectedUser]);
// only send nextQuestion if the quiz has started
if (!quizStarted) {
console.log(`!quizStarted: returning.... `);
return;
}
if (quizMode === 'teacher') {
webSocketService.nextQuestion({
roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: Number(currentQuestion?.question.id) - 1,
isLaunch: true // started late
});
} else if (quizMode === 'student') {
webSocketService.launchStudentModeQuiz(formattedRoomName, quizQuestions);
} else {
console.error('Invalid quiz mode:', quizMode);
}
// Reset the newly connected user state
setNewlyConnectedUser(null);
}
}, [newlyConnectedUser]);
useEffect(() => { useEffect(() => {
const verifyLogin = async () => { if (quizId.id) {
if (!ApiService.isLoggedIn()) { const fetchquiz = async () => {
navigate('/teacher/login');
return;
}
};
verifyLogin(); const quiz = await ApiService.getQuiz(quizId.id as string);
}, []);
useEffect(() => {
if (!roomName) {
console.error('Room name is missing!');
return;
}
console.log(`Joining room: ${roomName}`);
}, [roomName]);
useEffect(() => {
if (!roomName || !quizId) {
window.alert(
`Une erreur est survenue.\n La salle ou le quiz n'a pas été spécifié.\nVeuillez réessayer plus tard.`
);
console.error(`Room "${roomName}" or Quiz "${quizId}" not found.`);
navigate('/teacher/dashboard');
}
if (roomName && !socket) {
createWebSocketRoom();
}
return () => {
disconnectWebSocket();
};
}, [roomName, navigate]);
useEffect(() => {
if (quizId) {
const fetchQuiz = async () => {
const quiz = await ApiService.getQuiz(quizId);
if (!quiz) { if (!quiz) {
window.alert( window.alert(`Une erreur est survenue.\n Le quiz ${quizId.id} n'a pas été trouvé\nVeuillez réessayer plus tard`)
`Une erreur est survenue.\n Le quiz ${quizId} n'a pas été trouvé\nVeuillez réessayer plus tard` console.error('Quiz not found for id:', quizId.id);
);
console.error('Quiz not found for id:', quizId);
navigate('/teacher/dashboard'); navigate('/teacher/dashboard');
return; return;
} }
setQuiz(quiz as QuizType); setQuiz(quiz as QuizType);
if (!socket) {
console.log(`no socket in ManageRoom, creating one.`);
createWebSocketRoom();
}
// return () => {
// webSocketService.disconnect();
// };
}; };
fetchQuiz(); fetchquiz();
} else { } else {
window.alert( window.alert(`Une erreur est survenue.\n Le quiz ${quizId.id} n'a pas été trouvé\nVeuillez réessayer plus tard`)
`Une erreur est survenue.\n Le quiz ${quizId} n'a pas été trouvé\nVeuillez réessayer plus tard` console.error('Quiz not found for id:', quizId.id);
);
console.error('Quiz not found for id:', quizId);
navigate('/teacher/dashboard'); navigate('/teacher/dashboard');
return; return;
} }
@ -152,115 +71,109 @@ const ManageRoom: React.FC = () => {
const disconnectWebSocket = () => { const disconnectWebSocket = () => {
if (socket) { if (socket) {
webSocketService.endQuiz(formattedRoomName); webSocketService.endQuiz(roomName);
webSocketService.disconnect(); webSocketService.disconnect();
setSocket(null); setSocket(null);
setQuizQuestions(undefined); setQuizQuestions(undefined);
setCurrentQuestion(undefined); setCurrentQuestion(undefined);
setStudents(new Array<StudentType>()); setStudents(new Array<StudentType>());
setRoomName('');
} }
}; };
const createWebSocketRoom = () => { const createWebSocketRoom = () => {
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL); console.log('Creating WebSocket room...');
const roomNameUpper = roomName.toUpperCase(); setConnectingError('');
setFormattedRoomName(roomNameUpper); const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL);
console.log(`Creating WebSocket room named ${roomNameUpper}`);
/**
* ATTENTION: Lire les variables d'état dans
* les .on() n'est pas une bonne pratique.
* Les valeurs sont celles au moment de la création
* de la fonction et non au moment de l'exécution.
* Il faut utiliser des refs pour les valeurs qui
* changent fréquemment. Sinon, utiliser un trigger
* de useEffect pour mettre déclencher un traitement
* (voir user-joined plus bas).
*/
socket.on('connect', () => { socket.on('connect', () => {
webSocketService.createRoom(roomNameUpper); webSocketService.createRoom();
}); });
socket.on('connect_error', (error) => { socket.on('connect_error', (error) => {
setConnectingError('Erreur lors de la connexion... Veuillez réessayer'); setConnectingError('Erreur lors de la connexion... Veuillez réessayer');
console.error('ManageRoom: WebSocket connection error:', error); console.error('ManageRoom: WebSocket connection error:', error);
}); });
socket.on('create-success', (roomName: string) => {
socket.on('create-success', (createdRoomName: string) => { setRoomName(roomName);
console.log(`Room created: ${createdRoomName}`); });
socket.on('create-failure', () => {
console.log('Error creating room.');
}); });
socket.on('user-joined', (student: StudentType) => { socket.on('user-joined', (student: StudentType) => {
setNewlyConnectedUser(student); console.log(`Student joined: name = ${student.name}, id = ${student.id}`);
});
setStudents((prevStudents) => [...prevStudents, student]);
if (quizMode === 'teacher') {
webSocketService.nextQuestion(roomName, currentQuestion);
} else if (quizMode === 'student') {
webSocketService.launchStudentModeQuiz(roomName, quizQuestions);
}
});
socket.on('join-failure', (message) => { socket.on('join-failure', (message) => {
setConnectingError(message); setConnectingError(message);
setSocket(null); setSocket(null);
}); });
socket.on('user-disconnected', (userId: string) => { socket.on('user-disconnected', (userId: string) => {
console.log(`Student left: id = ${userId}`); console.log(`Student left: id = ${userId}`);
setStudents((prevUsers) => prevUsers.filter((user) => user.id !== userId)); setStudents((prevUsers) => prevUsers.filter((user) => user.id !== userId));
}); });
setSocket(socket); setSocket(socket);
}; };
useEffect(() => { useEffect(() => {
// This is here to make sure the correct value is sent when user join
if (socket) { if (socket) {
console.log(`Listening for submit-answer-room in room ${formattedRoomName}`); console.log(`Listening for user-joined in room ${roomName}`);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
socket.on('user-joined', (_student: StudentType) => {
if (quizMode === 'teacher') {
webSocketService.nextQuestion(roomName, currentQuestion);
} else if (quizMode === 'student') {
webSocketService.launchStudentModeQuiz(roomName, quizQuestions);
}
});
}
if (socket) {
// handle the case where user submits an answer
console.log(`Listening for submit-answer-room in room ${roomName}`);
socket.on('submit-answer-room', (answerData: AnswerReceptionFromBackendType) => { socket.on('submit-answer-room', (answerData: AnswerReceptionFromBackendType) => {
const { answer, idQuestion, idUser, username } = answerData; const { answer, idQuestion, idUser, username } = answerData;
console.log( console.log(`Received answer from ${username} for question ${idQuestion}: ${answer}`);
`Received answer from ${username} for question ${idQuestion}: ${answer}`
);
if (!quizQuestions) { if (!quizQuestions) {
console.log('Quiz questions not found (cannot update answers without them).'); console.log('Quiz questions not found (cannot update answers without them).');
return; return;
} }
// Update the students state using the functional form of setStudents // Update the students state using the functional form of setStudents
setStudents((prevStudents) => { setStudents((prevStudents) => {
// print the list of current student names
console.log('Current students:'); console.log('Current students:');
prevStudents.forEach((student) => { prevStudents.forEach((student) => {
console.log(student.name); console.log(student.name);
}); });
let foundStudent = false; let foundStudent = false;
const updatedStudents = prevStudents.map((student) => { const updatedStudents = prevStudents.map((student) => {
console.log(`Comparing ${student.id} to ${idUser}`); console.log(`Comparing ${student.id} to ${idUser}`);
if (student.id === idUser) { if (student.id === idUser) {
foundStudent = true; foundStudent = true;
const existingAnswer = student.answers.find( const existingAnswer = student.answers.find((ans) => ans.idQuestion === idQuestion);
(ans) => ans.idQuestion === idQuestion
);
let updatedAnswers: Answer[] = []; let updatedAnswers: Answer[] = [];
if (existingAnswer) { if (existingAnswer) {
// Update the existing answer
updatedAnswers = student.answers.map((ans) => { updatedAnswers = student.answers.map((ans) => {
console.log(`Comparing ${ans.idQuestion} to ${idQuestion}`); console.log(`Comparing ${ans.idQuestion} to ${idQuestion}`);
return ans.idQuestion === idQuestion return (ans.idQuestion === idQuestion ? { ...ans, answer, isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!) } : ans);
? {
...ans,
answer,
isCorrect: checkIfIsCorrect(
answer,
idQuestion,
quizQuestions!
)
}
: ans;
}); });
} else { } else {
const newAnswer = { // Add a new answer
idQuestion, const newAnswer = { idQuestion, answer, isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!) };
answer,
isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!)
};
updatedAnswers = [...student.answers, newAnswer]; updatedAnswers = [...student.answers, newAnswer];
} }
return { ...student, answers: updatedAnswers }; return { ...student, answers: updatedAnswers };
} }
return student; return student;
}); });
if (!foundStudent) { if (!foundStudent) {
@ -271,8 +184,73 @@ const ManageRoom: React.FC = () => {
}); });
setSocket(socket); setSocket(socket);
} }
}, [socket, currentQuestion, quizQuestions]); }, [socket, currentQuestion, quizQuestions]);
// useEffect(() => {
// if (socket) {
// const submitAnswerHandler = (answerData: answerSubmissionType) => {
// const { answer, idQuestion, username } = answerData;
// console.log(`Received answer from ${username} for question ${idQuestion}: ${answer}`);
// // print the list of current student names
// console.log('Current students:');
// students.forEach((student) => {
// console.log(student.name);
// });
// // Update the students state using the functional form of setStudents
// setStudents((prevStudents) => {
// let foundStudent = false;
// const updatedStudents = prevStudents.map((student) => {
// if (student.id === username) {
// foundStudent = true;
// const updatedAnswers = student.answers.map((ans) => {
// const newAnswer: Answer = { answer, isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!), idQuestion };
// console.log(`Updating answer for ${student.name} for question ${idQuestion} to ${answer}`);
// return (ans.idQuestion === idQuestion ? { ...ans, newAnswer } : ans);
// }
// );
// return { ...student, answers: updatedAnswers };
// }
// return student;
// });
// if (!foundStudent) {
// console.log(`Student ${username} not found in the list of students in LiveResults`);
// }
// return updatedStudents;
// });
// // make a copy of the students array so we can update it
// // const updatedStudents = [...students];
// // const student = updatedStudents.find((student) => student.id === idUser);
// // if (!student) {
// // // this is a bad thing if an answer was submitted but the student isn't in the list
// // console.log(`Student ${idUser} not found in the list of students in LiveResults`);
// // return;
// // }
// // const isCorrect = checkIfIsCorrect(answer, idQuestion);
// // const newAnswer: Answer = { answer, isCorrect, idQuestion };
// // student.answers.push(newAnswer);
// // // print list of answers
// // console.log('Answers:');
// // student.answers.forEach((answer) => {
// // console.log(answer.answer);
// // });
// // setStudents(updatedStudents); // update the state
// };
// socket.on('submit-answer', submitAnswerHandler);
// return () => {
// socket.off('submit-answer');
// };
// }
// }, [socket]);
const nextQuestion = () => { const nextQuestion = () => {
if (!quizQuestions || !currentQuestion || !quiz?.content) return; if (!quizQuestions || !currentQuestion || !quiz?.content) return;
@ -281,12 +259,7 @@ const ManageRoom: React.FC = () => {
if (nextQuestionIndex === undefined || nextQuestionIndex > quizQuestions.length - 1) return; if (nextQuestionIndex === undefined || nextQuestionIndex > quizQuestions.length - 1) return;
setCurrentQuestion(quizQuestions[nextQuestionIndex]); setCurrentQuestion(quizQuestions[nextQuestionIndex]);
webSocketService.nextQuestion({ webSocketService.nextQuestion(roomName, quizQuestions[nextQuestionIndex]);
roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: nextQuestionIndex,
isLaunch: false
});
}; };
const previousQuestion = () => { const previousQuestion = () => {
@ -296,12 +269,7 @@ const ManageRoom: React.FC = () => {
if (prevQuestionIndex === undefined || prevQuestionIndex < 0) return; if (prevQuestionIndex === undefined || prevQuestionIndex < 0) return;
setCurrentQuestion(quizQuestions[prevQuestionIndex]); setCurrentQuestion(quizQuestions[prevQuestionIndex]);
webSocketService.nextQuestion({ webSocketService.nextQuestion(roomName, quizQuestions[prevQuestionIndex]);
roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: prevQuestionIndex,
isLaunch: false
});
}; };
const initializeQuizQuestion = () => { const initializeQuizQuestion = () => {
@ -329,12 +297,7 @@ const ManageRoom: React.FC = () => {
} }
setCurrentQuestion(quizQuestions[0]); setCurrentQuestion(quizQuestions[0]);
webSocketService.nextQuestion({ webSocketService.nextQuestion(roomName, quizQuestions[0]);
roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: 0,
isLaunch: true
});
}; };
const launchStudentMode = () => { const launchStudentMode = () => {
@ -346,19 +309,15 @@ const ManageRoom: React.FC = () => {
return; return;
} }
setQuizQuestions(quizQuestions); setQuizQuestions(quizQuestions);
webSocketService.launchStudentModeQuiz(formattedRoomName, quizQuestions); webSocketService.launchStudentModeQuiz(roomName, quizQuestions);
}; };
const launchQuiz = () => { const launchQuiz = () => {
setQuizStarted(true); if (!socket || !roomName || !quiz?.content || quiz?.content.length === 0) {
if (!socket || !formattedRoomName || !quiz?.content || quiz?.content.length === 0) {
// TODO: This error happens when token expires! Need to handle it properly // TODO: This error happens when token expires! Need to handle it properly
console.log( console.log(`Error launching quiz. socket: ${socket}, roomName: ${roomName}, quiz: ${quiz}`);
`Error launching quiz. socket: ${socket}, roomName: ${formattedRoomName}, quiz: ${quiz}`
);
return; return;
} }
console.log(`Launching quiz in ${quizMode} mode...`);
switch (quizMode) { switch (quizMode) {
case 'student': case 'student':
return launchStudentMode(); return launchStudentMode();
@ -370,28 +329,74 @@ const ManageRoom: React.FC = () => {
const showSelectedQuestion = (questionIndex: number) => { const showSelectedQuestion = (questionIndex: number) => {
if (quiz?.content && quizQuestions) { if (quiz?.content && quizQuestions) {
setCurrentQuestion(quizQuestions[questionIndex]); setCurrentQuestion(quizQuestions[questionIndex]);
if (quizMode === 'teacher') { if (quizMode === 'teacher') {
webSocketService.nextQuestion({ webSocketService.nextQuestion(roomName, quizQuestions[questionIndex]);
roomName: formattedRoomName,
questions: quizQuestions,
questionIndex,
isLaunch: false
});
} }
} }
}; };
const finishQuiz = () => {
disconnectWebSocket();
navigate('/teacher/dashboard');
};
const handleReturn = () => { const handleReturn = () => {
disconnectWebSocket(); disconnectWebSocket();
navigate('/teacher/dashboard'); navigate('/teacher/dashboard');
}; };
if (!formattedRoomName) { function checkIfIsCorrect(answer: string | number | boolean, idQuestion: number, questions: QuestionType[]): boolean {
const questionInfo = questions.find((q) =>
q.question.id ? q.question.id === idQuestion.toString() : false
) as QuestionType | undefined;
const answerText = answer.toString();
if (questionInfo) {
const question = questionInfo.question as ParsedGIFTQuestion;
if (question.type === 'TF') {
return (
(question.isTrue && answerText == 'true') ||
(!question.isTrue && answerText == 'false')
);
} else if (question.type === 'MC') {
return question.choices.some(
(choice) => choice.isCorrect && choice.formattedText.text === answerText
);
} else if (question.type === 'Numerical') {
if (isHighLowNumericalAnswer(question.choices[0])) {
const choice = question.choices[0];
const answerNumber = parseFloat(answerText);
if (!isNaN(answerNumber)) {
return (
answerNumber <= choice.numberHigh &&
answerNumber >= choice.numberLow
);
}
}
if (isRangeNumericalAnswer(question.choices[0])) {
const answerNumber = parseFloat(answerText);
const range = question.choices[0].range;
const correctAnswer = question.choices[0].number;
if (!isNaN(answerNumber)) {
return (
answerNumber <= correctAnswer + range &&
answerNumber >= correctAnswer - range
);
}
}
if (isSimpleNumericalAnswer(question.choices[0])) {
const answerNumber = parseFloat(answerText);
if (!isNaN(answerNumber)) {
return answerNumber === question.choices[0].number;
}
}
} else if (question.type === 'Short') {
return question.choices.some(
(choice) => choice.text.toUpperCase() === answerText.toUpperCase()
);
}
}
return false;
}
if (!roomName) {
return ( return (
<div className="center"> <div className="center">
{!connectingError ? ( {!connectingError ? (
@ -414,114 +419,33 @@ const ManageRoom: React.FC = () => {
} }
return ( return (
<div className="room"> <div className='room'>
{/* En-tête avec bouton Disconnect à gauche et QR code à droite */} <div className='roomHeader'>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
}}
>
<DisconnectButton <DisconnectButton
onReturn={handleReturn} onReturn={handleReturn}
askConfirm askConfirm
message={`Êtes-vous sûr de vouloir quitter?`} message={`Êtes-vous sûr de vouloir quitter?`} />
/>
<Button <div className='centerTitle'>
variant="contained" <div className='title'>Salle: {roomName}</div>
color="primary" <div className='userCount subtitle'>Utilisateurs: {students.length}/60</div>
onClick={() => setShowQrModal(true)}
startIcon={<QRCodeIcon />}
>
Lien de participation
</Button>
</div>
<Dialog
open={showQrModal}
onClose={() => setShowQrModal(false)}
aria-labelledby="qr-modal-title"
>
<DialogTitle id="qr-modal-title">
Rejoindre la salle: {formattedRoomName}
</DialogTitle>
<DialogContent>
<DialogContentText>
Scannez ce QR code ou partagez le lien ci-dessous pour rejoindre la salle :
</DialogContentText>
<div style={{ textAlign: 'center', margin: '20px 0' }}>
<QRCodeCanvas value={roomUrl} size={256} />
</div>
<div style={{ wordBreak: 'break-all', textAlign: 'center' }}>
<h3>URL de participation :</h3>
<p>{roomUrl}</p>
<Button
variant="contained"
startIcon={<ContentCopyIcon />}
onClick={handleCopy}
style={{ marginTop: '10px' }}
>
{copied ? 'Copié !' : 'Copier le lien'}
</Button>
</div>
</DialogContent>
<DialogActions>
<Button onClick={() => setShowQrModal(false)} color="primary">
Fermer
</Button>
</DialogActions>
</Dialog>
<div className="roomHeader">
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
marginBottom: '10px'
}}
>
<h1 style={{ margin: 0, display: 'flex', alignItems: 'center' }}>
Salle : {formattedRoomName}
<div
className="userCount subtitle"
style={{
display: 'inline-flex',
alignItems: 'center',
fontSize: '1.5rem',
fontWeight: 'bold',
marginLeft: '20px',
marginBottom: '0px'
}}
>
<GroupIcon style={{ marginRight: '5px', verticalAlign: 'middle' }} />{' '}
{students.length}/60
</div>
</h1>
</div> </div>
<div className="dumb"></div> <div className='dumb'></div>
</div>
</div>
{/* the following breaks the css (if 'room' classes are nested) */} {/* the following breaks the css (if 'room' classes are nested) */}
<div className=""> <div className=''>
{quizQuestions ? ( {quizQuestions ? (
<div style={{ display: 'flex', flexDirection: 'column' }}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<div className="title center-h-align mb-2">{quiz?.title}</div> <div className="title center-h-align mb-2">{quiz?.title}</div>
{!isNaN(Number(currentQuestion?.question.id)) && (
<strong className="number of questions">
Question {Number(currentQuestion?.question.id)}/
{quizQuestions?.length}
</strong>
)}
{quizMode === 'teacher' && ( {quizMode === 'teacher' && (
<div className="mb-1"> <div className="mb-1">
{/* <QuestionNavigation {/* <QuestionNavigation
currentQuestionId={Number(currentQuestion?.question.id)} currentQuestionId={Number(currentQuestion?.question.id)}
@ -530,10 +454,12 @@ const ManageRoom: React.FC = () => {
nextQuestion={nextQuestion} nextQuestion={nextQuestion}
/> */} /> */}
</div> </div>
)} )}
<div className="mb-2 flex-column-wrapper"> <div className="mb-2 flex-column-wrapper">
<div className="preview-and-result-container"> <div className="preview-and-result-container">
{currentQuestion && ( {currentQuestion && (
<QuestionDisplay <QuestionDisplay
showAnswer={false} showAnswer={false}
@ -548,51 +474,42 @@ const ManageRoom: React.FC = () => {
showSelectedQuestion={showSelectedQuestion} showSelectedQuestion={showSelectedQuestion}
students={students} students={students}
></LiveResultsComponent> ></LiveResultsComponent>
</div> </div>
</div> </div>
{quizMode === 'teacher' && ( {quizMode === 'teacher' && (
<div <div className="questionNavigationButtons" style={{ display: 'flex', justifyContent: 'center' }}>
className="questionNavigationButtons" <div className="previousQuestionButton">
style={{ display: 'flex', justifyContent: 'center' }} <Button onClick={previousQuestion}
> variant="contained"
<div className="previousQuestionButton"> disabled={Number(currentQuestion?.question.id) <= 1}>
<Button Question précédente
onClick={previousQuestion} </Button>
variant="contained"
disabled={Number(currentQuestion?.question.id) <= 1}
>
Question précédente
</Button>
</div>
<div className="nextQuestionButton">
<Button
onClick={nextQuestion}
variant="contained"
disabled={
Number(currentQuestion?.question.id) >=
quizQuestions.length
}
>
Prochaine question
</Button>
</div>
</div> </div>
)} <div className="nextQuestionButton">
<div className="finishQuizButton"> <Button onClick={nextQuestion}
<Button onClick={finishQuiz} variant="contained"> variant="contained"
Terminer le quiz disabled={Number(currentQuestion?.question.id) >=quizQuestions.length}
</Button> >
</div> Prochaine question
</Button>
</div>
</div> )}
</div> </div>
) : ( ) : (
<StudentWaitPage <StudentWaitPage
students={students} students={students}
launchQuiz={launchQuiz} launchQuiz={launchQuiz}
setQuizMode={setQuizMode} setQuizMode={setQuizMode}
/> />
)} )}
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,59 +0,0 @@
import { useState, useEffect } from 'react';
import ApiService from '../../../services/ApiService';
import { RoomType } from 'src/Types/RoomType';
import React from "react";
import { RoomContext } from './useRooms';
export const RoomProvider = ({ children }: { children: React.ReactNode }) => {
const [rooms, setRooms] = useState<RoomType[]>([]);
const [selectedRoom, setSelectedRoom] = useState<RoomType | null>(null);
useEffect(() => {
const loadRooms = async () => {
const userRooms = await ApiService.getUserRooms();
const roomsList = userRooms as RoomType[];
setRooms(roomsList);
const savedRoomId = localStorage.getItem('selectedRoomId');
if (savedRoomId) {
const savedRoom = roomsList.find(r => r._id === savedRoomId);
if (savedRoom) {
setSelectedRoom(savedRoom);
return;
}
}
if (roomsList.length > 0) {
setSelectedRoom(roomsList[0]);
localStorage.setItem('selectedRoomId', roomsList[0]._id);
}
};
loadRooms();
}, []);
// Sélectionner une salle
const selectRoom = (roomId: string) => {
const room = rooms.find(r => r._id === roomId) || null;
setSelectedRoom(room);
localStorage.setItem('selectedRoomId', roomId);
};
// Créer une salle
const createRoom = async (title: string) => {
// Créer la salle et récupérer l'objet complet
const newRoom = await ApiService.createRoom(title);
// Mettre à jour la liste des salles
const updatedRooms = await ApiService.getUserRooms();
setRooms(updatedRooms as RoomType[]);
// Sélectionner la nouvelle salle avec son ID
selectRoom(newRoom); // Utiliser l'ID de l'objet retourné
};
return (
<RoomContext.Provider value={{ rooms, selectedRoom, selectRoom, createRoom }}>
{children}
</RoomContext.Provider>
);
};

View file

@ -2,31 +2,25 @@
.room .roomHeader { .room .roomHeader {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: row;
align-items: flex-start; justify-content: space-between;
position: relative; align-content: stretch
} }
.room .roomHeader .returnButton { .room .roomHeader .returnButton {
position: absolute; flex-basis: 10%;
top: 10px;
left: 0; display: flex;
z-index: 10; justify-content: center;
} }
.room .roomHeader .centerTitle { .room .roomHeader .centerTitle {
flex-basis: auto; flex-basis: auto;
display: flex;
justify-content: flex-start;
align-items: flex-start;
margin-top: 40px;
}
.room .roomHeader .headerContent {
width: 100%;
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
margin-top: 60px;
} }
.room .roomHeader .dumb { .room .roomHeader .dumb {
@ -40,16 +34,152 @@
overflow: auto; overflow: auto;
justify-content: center; justify-content: center;
/* align-items: center; */
} }
.room .finishQuizButton {
/* .create-room-container {
display: flex; display: flex;
justify-content: flex-end; flex-direction: column;
margin-left: auto; align-items: center;
justify-content: center;
height: 100%;
}
.manage-room-container {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
width: 100%; width: 100%;
} }
.room h1 { .quiz-setup-container {
text-align: center; display: flex;
margin-top: 50px; flex-direction: column;
} width: 100%;
margin-top: 2rem;
}
.quiz-mode-selection {
display: flex;
flex-grow: 0;
flex-direction: column;
justify-content: center;
align-items: center;
margin-top: 10px;
height: 15vh;
}
.users-container {
display: flex;
flex-direction: column;
align-items: center;
flex-grow: 1;
gap: 2vh;
}
.launch-quiz-btn {
width: 20vw;
height: 11vh;
margin-top: 2vh;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
.mode-choice {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 20vw;
margin-top: 2vh;
}
.user {
background-color: #e7dad1;
padding: 10px 20px;
border: 1px solid black;
border-radius: 10px;
box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2);
}
.bottom-btn {
display: flex;
width: 100%;
justify-content: flex-end;
margin-top: 2vh;
}
.room-container {
position: relative;
width: 100%;
max-width: 60vw;
}
@media only screen and (max-device-width: 768px) {
.room-container {
max-width: 100%;
}
}
.room-wrapper {
display: flex;
width: 100%;
height: 100%;
justify-content: center;
}
.room-name-wrapper {
display: flex;
flex-direction: column;
align-items: end;
}
.user-item {
width: 100%;
}
.flex-column-wrapper {
display: flex;
flex-direction: column;
height: 85vh;
overflow: auto;
}
.preview-and-result-container {
display: flex;
flex-direction: column;
gap: 2rem;
}
.nextQuestionButton {
align-self: flex-end;
margin-bottom: 5rem !important;
}
.top-container {
display: flex;
justify-content: space-between;
align-items: center;
}
@media only screen and (max-device-height: 4000px) {
.flex-column-wrapper {
height: 60vh;
}
}
@media only screen and (max-device-height: 1079px) {
.flex-column-wrapper {
height: 50vh;
}
}
@media only screen and (max-device-height: 741px) {
.flex-column-wrapper {
height: 40vh;
}
} */

View file

@ -1,161 +0,0 @@
import { useContext } from 'react';
import { RoomType } from 'src/Types/RoomType';
import { createContext } from 'react';
import { MultipleNumericalAnswer, NumericalAnswer, ParsedGIFTQuestion } from 'gift-pegjs';
import { QuestionType } from 'src/Types/QuestionType';
import { AnswerType } from 'src/pages/Student/JoinRoom/JoinRoom';
import {
isSimpleNumericalAnswer,
isRangeNumericalAnswer,
isHighLowNumericalAnswer,
isMultipleNumericalAnswer
} from 'gift-pegjs/typeGuards';
type RoomContextType = {
rooms: RoomType[];
selectedRoom: RoomType | null;
selectRoom: (roomId: string) => void;
createRoom: (title: string) => Promise<void>;
};
export const RoomContext = createContext<RoomContextType | undefined>(undefined);
export const useRooms = () => {
const context = useContext(RoomContext);
if (!context) throw new Error('useRooms must be used within a RoomProvider');
return context;
};
/**
* Checks if the answer is correct - logic varies by type of question!
* True/False: answer must match the isTrue property
* Multiple Choice: answer must match the correct choice(s)
* Numerical: answer must be within the range or equal to the number (for each type of correct answer)
* Short Answer: answer must match the correct choice(s) (case-insensitive)
* @param answer
* @param idQuestion
* @param questions
* @returns
*/
export function checkIfIsCorrect(
answer: AnswerType,
idQuestion: number,
questions: QuestionType[]
): boolean {
const questionInfo = questions.find((q) =>
q.question.id ? q.question.id === idQuestion.toString() : false
) as QuestionType | undefined;
const simpleAnswerText = answer.toString();
if (questionInfo) {
const question = questionInfo.question as ParsedGIFTQuestion;
if (question.type === 'TF') {
return (
(question.isTrue && simpleAnswerText == 'true') ||
(!question.isTrue && simpleAnswerText == 'false')
);
} else if (question.type === 'MC') {
const correctChoices = question.choices.filter((choice) => choice.isCorrect
/* || (choice.weight && choice.weight > 0)*/ // handle weighted answers
);
const multipleAnswers = Array.isArray(answer) ? answer : [answer as string];
if (correctChoices.length === 0) {
return false;
}
// check if all (and only) correct choices are in the multipleAnswers array
return correctChoices.length === multipleAnswers.length && correctChoices.every(
(choice) => multipleAnswers.includes(choice.formattedText.text)
);
} else if (question.type === 'Numerical') {
if (isMultipleNumericalAnswer(question.choices[0])) { // Multiple numerical answers
// check to see if answer[0] is a match for any of the choices that isCorrect
const correctChoices = question.choices.filter((choice) => isMultipleNumericalAnswer(choice) && choice.isCorrect);
if (correctChoices.length === 0) { // weird case where there are multiple numerical answers but none are correct
return false;
}
return correctChoices.some((choice) => {
// narrow choice to MultipleNumericalAnswer type
const multipleNumericalChoice = choice as MultipleNumericalAnswer;
return isCorrectNumericalAnswer(multipleNumericalChoice.answer, simpleAnswerText);
});
}
if (isHighLowNumericalAnswer(question.choices[0])) {
// const choice = question.choices[0];
// const answerNumber = parseFloat(simpleAnswerText);
// if (!isNaN(answerNumber)) {
// return (
// answerNumber <= choice.numberHigh && answerNumber >= choice.numberLow
// );
// }
return isCorrectNumericalAnswer(question.choices[0], simpleAnswerText);
}
if (isRangeNumericalAnswer(question.choices[0])) {
// const answerNumber = parseFloat(simpleAnswerText);
// const range = question.choices[0].range;
// const correctAnswer = question.choices[0].number;
// if (!isNaN(answerNumber)) {
// return (
// answerNumber <= correctAnswer + range &&
// answerNumber >= correctAnswer - range
// );
// }
return isCorrectNumericalAnswer(question.choices[0], simpleAnswerText);
}
if (isSimpleNumericalAnswer(question.choices[0])) {
// const answerNumber = parseFloat(simpleAnswerText);
// if (!isNaN(answerNumber)) {
// return answerNumber === question.choices[0].number;
// }
return isCorrectNumericalAnswer(question.choices[0], simpleAnswerText);
}
} else if (question.type === 'Short') {
return question.choices.some(
(choice) => choice.text.toUpperCase() === simpleAnswerText.toUpperCase()
);
}
}
return false;
}
/**
* Determines if a numerical answer is correct based on the type of numerical answer.
* @param correctAnswer The correct answer (of type NumericalAnswer).
* @param userAnswer The user's answer (as a string or number).
* @returns True if the user's answer is correct, false otherwise.
*/
export function isCorrectNumericalAnswer(
correctAnswer: NumericalAnswer,
userAnswer: string | number
): boolean {
const answerNumber = typeof userAnswer === 'string' ? parseFloat(userAnswer) : userAnswer;
if (isNaN(answerNumber)) {
return false; // User's answer is not a valid number
}
if (isSimpleNumericalAnswer(correctAnswer)) {
// Exact match for simple numerical answers
return answerNumber === correctAnswer.number;
}
if (isRangeNumericalAnswer(correctAnswer)) {
// Check if the user's answer is within the range
const { number, range } = correctAnswer;
return answerNumber >= number - range && answerNumber <= number + range;
}
if (isHighLowNumericalAnswer(correctAnswer)) {
// Check if the user's answer is within the high-low range
const { numberLow, numberHigh } = correctAnswer;
return answerNumber >= numberLow && answerNumber <= numberHigh;
}
// if (isMultipleNumericalAnswer(correctAnswer)) {
// // Check if the user's answer matches any of the multiple numerical answers
// return correctAnswer.answer.some((choice) =>
// isCorrectNumericalAnswer(choice, answerNumber)
// );
// }
return false; // Default to false if the answer type is not recognized
}

View file

@ -1,88 +1,81 @@
import { Link } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
// JoinRoom.tsx
import React, { useEffect, useState } from 'react'; // JoinRoom.tsx
import React, { useEffect, useState } from 'react';
import '../css/simpleLogin.css';
import { TextField } from '@mui/material'; import { TextField } from '@mui/material';
import LoadingButton from '@mui/lab/LoadingButton'; import LoadingButton from '@mui/lab/LoadingButton';
import LoginContainer from '../../../../components/LoginContainer/LoginContainer' import LoginContainer from 'src/components/LoginContainer/LoginContainer'
import ApiService from '../../../../services/ApiService'; import ApiService from '../../../services/ApiService';
const SimpleLogin: React.FC = () => { const Register: React.FC = () => {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [connectionError, setConnectionError] = useState<string>('');
const [isConnecting] = useState<boolean>(false); const [connectionError, setConnectionError] = useState<string>('');
const [isConnecting] = useState<boolean>(false);
useEffect(() => {
return () => { useEffect(() => {
return () => {
};
}, []); };
}, []);
const login = async () => {
console.log(`SimpleLogin: login: email: ${email}, password: ${password}`); const register = async () => {
const result = await ApiService.login(email, password); const result = await ApiService.register(email, password);
if (result !== true) {
setConnectionError(result); if (typeof result === 'string') {
return; setConnectionError(result);
} return;
}; }
navigate("/teacher/login")
return ( };
<LoginContainer
title=''
error={connectionError}> return (
<LoginContainer
<TextField title='Créer un compte'
label="Email" error={connectionError}>
variant="outlined"
value={email} <TextField
onChange={(e) => setEmail(e.target.value)} label="Email"
sx={{ marginBottom: '1rem' }} variant="outlined"
fullWidth value={email}
/> onChange={(e) => setEmail(e.target.value)}
placeholder="Adresse courriel"
<TextField sx={{ marginBottom: '1rem' }}
label="Mot de passe" fullWidth
variant="outlined" />
type="password"
value={password} <TextField
onChange={(e) => setPassword(e.target.value)} label="Mot de passe"
sx={{ marginBottom: '1rem' }} variant="outlined"
fullWidth value={password}
/> type="password"
onChange={(e) => setPassword(e.target.value)}
<LoadingButton placeholder="Mot de passe"
loading={isConnecting} sx={{ marginBottom: '1rem' }}
onClick={login} fullWidth
variant="contained" />
sx={{ marginBottom: `${connectionError && '2rem'}` }}
disabled={!email || !password} <LoadingButton
> loading={isConnecting}
Login onClick={register}
</LoadingButton> variant="contained"
sx={{ marginBottom: `${connectionError && '2rem'}` }}
<div className="login-links"> disabled={!email || !password}
>
S&apos;inscrire
{/* <Link to="/resetPassword"> */} </LoadingButton>
<del>Réinitialiser le mot de passe</del>
{/* </Link> */} </LoginContainer>
<Link to="/register"> );
Créer un compte };
</Link>
export default Register;
</div>
</LoginContainer>
);
};
export default SimpleLogin;

View file

@ -1,72 +1,66 @@
// EditorQuiz.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { FolderType } from '../../../Types/FolderType'; import { FolderType } from '../../../Types/FolderType';
import './share.css'; import './share.css';
import { Button, NativeSelect, Typography, Box } from '@mui/material'; import { Button, NativeSelect } from '@mui/material';
import ReturnButton from 'src/components/ReturnButton/ReturnButton'; import ReturnButton from 'src/components/ReturnButton/ReturnButton';
import ApiService from '../../../services/ApiService'; import ApiService from '../../../services/ApiService';
import SaveIcon from '@mui/icons-material/Save';
const Share: React.FC = () => { const Share: React.FC = () => {
console.log('Component rendered');
const navigate = useNavigate(); const navigate = useNavigate();
const { id } = useParams<string>(); const { id } = useParams<string>();
const [quizTitle, setQuizTitle] = useState(''); const [quizTitle, setQuizTitle] = useState('');
const [selectedFolder, setSelectedFolder] = useState<string>(''); const [selectedFolder, setSelectedFolder] = useState<string>('');
const [folders, setFolders] = useState<FolderType[]>([]); const [folders, setFolders] = useState<FolderType[]>([]);
const [quizExists, setQuizExists] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
try { console.log("QUIZID : " + id)
if (!id) { if (!id) {
console.error('Quiz not found for id:', id); window.alert(`Une erreur est survenue.\n Le quiz n'a pas été trouvé\nVeuillez réessayer plus tard`)
navigate('/teacher/dashboard'); console.error('Quiz not found for id:', id);
return;
}
if (!ApiService.isLoggedIn()) {
navigate("/login");
return;
}
const quizIds = await ApiService.getAllQuizIds();
if (quizIds.includes(id)) {
setQuizExists(true);
setLoading(false);
return;
}
const userFolders = await ApiService.getUserFolders();
if (userFolders.length == 0) {
navigate('/teacher/dashboard');
return;
}
setFolders(userFolders as FolderType[]);
const title = await ApiService.getSharedQuiz(id);
if (!title) {
console.error('Quiz not found for id:', id);
navigate('/teacher/dashboard');
return;
}
setQuizTitle(title);
setLoading(false);
} catch (error) {
console.error('Error fetching data:', error);
setLoading(false);
navigate('/teacher/dashboard'); navigate('/teacher/dashboard');
return;
} }
if (!ApiService.isLoggedIn()) {
window.alert(`Vous n'êtes pas connecté.\nVeuillez vous connecter et revenir à ce lien`);
navigate("/teacher/login");
return;
}
const userFolders = await ApiService.getUserFolders();
if (userFolders.length == 0) {
window.alert(`Vous n'avez aucun dossier.\nVeuillez en créer un et revenir à ce lien`)
navigate('/teacher/dashboard');
return;
}
setFolders(userFolders as FolderType[]);
const title = await ApiService.getSharedQuiz(id);
if (!title) {
window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`)
console.error('Quiz not found for id:', id);
navigate('/teacher/dashboard');
return;
}
setQuizTitle(title);
}; };
fetchData(); fetchData();
}, [id, navigate]); }, []);
const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedFolder(event.target.value); setSelectedFolder(event.target.value);
@ -74,12 +68,14 @@ const Share: React.FC = () => {
const handleQuizSave = async () => { const handleQuizSave = async () => {
try { try {
if (selectedFolder == '') { if (selectedFolder == '') {
alert("Veuillez choisir un dossier"); alert("Veuillez choisir un dossier");
return; return;
} }
if (!id) { if (!id) {
window.alert(`Une erreur est survenue.\n Le quiz n'a pas été trouvé\nVeuillez réessayer plus tard`)
console.error('Quiz not found for id:', id); console.error('Quiz not found for id:', id);
navigate('/teacher/dashboard'); navigate('/teacher/dashboard');
return; return;
@ -89,92 +85,49 @@ const Share: React.FC = () => {
navigate('/teacher/dashboard'); navigate('/teacher/dashboard');
} catch (error) { } catch (error) {
window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`)
console.log(error) console.log(error)
} }
}; };
if (loading) {
return <div className='quizImport'>Chargement...</div>;
}
if (quizExists) {
return (
<div className='quizImport'>
<div className='importHeader'>
<ReturnButton />
<div className='titleContainer'>
<div className='mainTitle'>Quiz déjà existant</div>
</div>
<div className='dumb'></div>
</div>
<div className='editSection'>
<Box sx={{
textAlign: 'center',
padding: 3,
maxWidth: 600,
margin: '0 auto'
}}>
<Typography variant="h6" gutterBottom>
Le quiz que vous essayez d'importer existe déjà sur votre compte.
</Typography>
<Button
variant="contained"
onClick={() => navigate('/teacher/dashboard')}
sx={{ mt: 3, mb: 1 }}
fullWidth
>
Retour au tableau de bord
</Button>
<Typography variant="body2" color="text.secondary">
Si vous souhaitiez créer une copie de ce quiz,
vous pouvez utiliser la fonction "Dupliquer" disponible
dans votre tableau de bord.
</Typography>
</Box>
</div>
</div>
);
}
return ( return (
<div className='quizImport'> <div className='quizImport'>
<div className='importHeader'> <div className='importHeader'>
<ReturnButton /> <ReturnButton />
<div className='titleContainer'>
<div className='mainTitle'>Importation du Quiz: {quizTitle}</div> <div className='title'>Importer quiz: {quizTitle}</div>
<div className='subTitle'>
Vous êtes sur le point d'importer le quiz <strong>{quizTitle}</strong>, choisissez un dossier dans lequel enregistrer ce nouveau quiz.
</div>
</div>
<div className='dumb'></div> <div className='dumb'></div>
</div> </div>
<div className='editSection'> <div className='editSection'>
<div className='formContainer'>
<div>
<NativeSelect <NativeSelect
id="select-folder" id="select-folder"
color="primary" color="primary"
value={selectedFolder} value={selectedFolder}
onChange={handleSelectFolder} onChange={handleSelectFolder}
className="folderSelect"
> >
<option disabled value=""> Choisir un dossier... </option> <option disabled value=""> Choisir un dossier... </option>
{folders.map((folder: FolderType) => ( {folders.map((folder: FolderType) => (
<option value={folder._id} key={folder._id}> {folder.title} </option> <option value={folder._id} key={folder._id}> {folder.title} </option>
))} ))}
</NativeSelect> </NativeSelect>
<Button variant="contained" onClick={handleQuizSave} className="saveButton"> <Button variant="contained" onClick={handleQuizSave}>
{<SaveIcon sx={{ fontSize: 20, marginRight: '8px' }} />}
Enregistrer Enregistrer
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
export default Share; export default Share;

View file

@ -3,58 +3,19 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; justify-content: space-between;
align-content: stretch; align-content: stretch
} }
.quizImport .importHeader .returnButton { .quizImport .importHeader .returnButton {
flex-basis: 10%; flex-basis: 10%;
display: flex; display: flex;
justify-content: center; justify-content: center;
} }
.quizImport .importHeader .titleContainer { .quizImport .importHeader .title {
flex-basis: auto; flex-basis: auto;
text-align: center; /* Center the title and subtitle */
}
.quizImport .importHeader .mainTitle {
font-size: 44px; /* Larger font size for the main title */
font-weight: bold; /* Remove bold */
color: #333; /* Slightly paler color */
}
.quizImport .importHeader .subTitle {
font-size: 14px; /* Smaller font size for the subtitle */
color: #666; /* Pale gray color */
margin-top: 8px; /* Add some space between the title and subtitle */
} }
.quizImport .importHeader .dumb { .quizImport .importHeader .dumb {
flex-basis: 10%; flex-basis: 10%;
}
.quizImport .editSection {
display: flex;
justify-content: center;
align-items: center;
margin-top: 20px;
}
.quizImport .editSection .formContainer {
display: flex;
flex-direction: column;
align-items: center;
gap: 20px; /* Adds space between the select and button */
width: 100%;
max-width: 400px; /* Limits the width of the form container */
}
.quizImport .editSection .folderSelect {
width: 100%; /* Ensures the select element takes the full width of its container */
}
.quizImport .editSection .saveButton {
width: 100%; /* Makes the button take the full width of its container */
padding: 10px 20px; /* Increases the button's padding for a larger appearance */
font-size: 16px; /* Increases the font size for better visibility */
} }

Some files were not shown because too many files have changed in this diff Show more