Merge remote-tracking branch 'origin/main' into philippe/quiz-to-pdf

This commit is contained in:
Philippe 2025-04-10 14:19:04 -04:00
commit 8da0a43f16
148 changed files with 11566 additions and 4167 deletions

38
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,38 @@
---
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

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

4
.gitignore vendored
View file

@ -41,6 +41,7 @@ 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/
@ -122,6 +123,9 @@ 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

View file

@ -0,0 +1,33 @@
{
"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,6 +3,7 @@ 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

30
README.fr-ca.md Normal file
View file

@ -0,0 +1,30 @@
[![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,24 +2,26 @@
[![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. * **Open Source and Self-Hosted**: Own and control your data by deploying the platform on your own 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. * **GIFT Compatibility**: Easily create quizzes using the GIFT format, enabling seamless integration with other learning systems.
* 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. * **Minimalist and Efficient**: A bare-bones approach to ensure simplicity and ease of use, focusing on the essentials of learning.
## Contribution ## 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) 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.
## Liens utiles ## Useful Links
* [Dépôt d'origine Frontend](https://github.com/ETS-PFE004-Plateforme-sondage-minitest/ETS-PFE004-EvalueTonSavoir-Frontend) * [Original Frontend Repository](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) * [Original Backend Repository](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,19 +0,0 @@
// 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,29 +1,77 @@
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 pluginJs from "@eslint/js"; import jest from "eslint-plugin-jest";
import tseslint from "typescript-eslint"; import reactRefresh from "eslint-plugin-react-refresh";
import pluginReact from "eslint-plugin-react"; import unusedImports from "eslint-plugin-unused-imports";
import eslintComments from "eslint-plugin-eslint-comments";
/** @type {import('eslint').Linter.Config[]} */ /** @type {import('eslint').Linter.Config[]} */
export default [ export default [
{ {
files: ["**/*.{js,mjs,cjs,ts,jsx,tsx}"], ignores: ["node_modules", "dist/**/*"],
},
{
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
languageOptions: { languageOptions: {
globals: globals.browser, parser: typescriptParser,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
},
globals: {
...globals.serviceworker,
...globals.browser,
...globals.jest,
...globals.node,
process: "readonly",
},
},
plugins: {
"@typescript-eslint": typescriptEslint,
react,
jest,
"react-refresh": reactRefresh,
"unused-imports": unusedImports,
"eslint-comments": eslintComments
}, },
rules: { rules: {
"no-unused-vars": ["error", { // Auto-fix unused variables
"argsIgnorePattern": "^_", "@typescript-eslint/no-unused-vars": "off",
"no-unused-vars": "off",
"unused-imports/no-unused-vars": [
"warn",
{
"vars": "all",
"varsIgnorePattern": "^_", "varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_" // Ignore catch clause parameters that start with _ "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: { settings: {
react: { react: {
version: "detect", // Automatically detect the React version version: "detect",
}, },
}, },
}, }
pluginJs.configs.recommended,
...tseslint.configs.recommended,
pluginReact.configs.flat.recommended,
]; ];

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/';

3346
client/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,70 +4,77 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --host", "dev": "cross-env MODE=development VITE_BACKEND_URL=http://localhost:4400 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", "test": "jest --colors --silent",
"test:watch": "jest --watch" "test:watch": "jest --watch"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.3", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.11.0", "@emotion/styled": "^11.14.0",
"@fortawesome/fontawesome-free": "^6.4.2", "@fortawesome/fontawesome-free": "^6.7.2",
"@fortawesome/fontawesome-svg-core": "^6.6.0", "@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2", "@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.0", "@fortawesome/react-fontawesome": "^0.2.0",
"@mui/icons-material": "^6.1.0", "@mui/icons-material": "^7.0.1",
"@mui/lab": "^5.0.0-alpha.153", "@mui/lab": "^5.0.0-alpha.153",
"@mui/material": "^6.1.0", "@mui/material": "^7.0.1",
"@types/uuid": "^9.0.7", "@types/uuid": "^9.0.7",
"axios": "^1.6.7", "axios": "^1.8.1",
"dompurify": "^3.2.3", "dompurify": "^3.2.5",
"esbuild": "^0.23.1", "esbuild": "^0.25.2",
"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": "^14.1.2", "marked": "^15.0.8",
"nanoid": "^5.0.2", "nanoid": "^5.1.5",
"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.1", "react-modal": "^3.16.3",
"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": "^9.0.1", "uuid": "^11.1.0",
"vite-plugin-checker": "^0.8.0" "vite-plugin-checker": "^0.9.1"
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-env": "^7.23.3", "@babel/preset-env": "^7.26.9",
"@babel/preset-react": "^7.23.3", "@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.23.3", "@babel/preset-typescript": "^7.27.0",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.24.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.5.0", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/node": "^22.5.5", "@types/node": "^22.14.0",
"@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.5.0", "@typescript-eslint/eslint-plugin": "^8.29.1",
"@typescript-eslint/parser": "^8.5.0", "@typescript-eslint/parser": "^8.29.1",
"@vitejs/plugin-react-swc": "^3.7.2", "@vitejs/plugin-react-swc": "^3.8.1",
"eslint": "^9.18.0", "cross-env": "^7.0.3",
"eslint-plugin-react": "^7.37.3", "eslint": "^9.24.0",
"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.12", "eslint-plugin-react-refresh": "^0.4.19",
"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.1.1", "ts-jest": "^29.3.1",
"typescript": "^5.6.2", "typescript": "^5.8.3",
"typescript-eslint": "^8.19.1", "typescript-eslint": "^8.29.1",
"vite": "^5.4.5", "vite": "^6.2.0",
"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';
// App.tsx import { useEffect, useState } from 'react';
import { Routes, Route } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
// Page main // Page main
import Home from './pages/Home/Home'; import Home from './pages/Home/Home';
@ -8,37 +8,55 @@ 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 Login from './pages/Teacher/Login/Login'; import Register from './pages/AuthManager/providers/SimpleLogin/Register';
import Register from './pages/Teacher/Register/Register'; import ResetPassword from './pages/AuthManager/providers/SimpleLogin/ResetPassword';
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 [isAuthenticated, setIsAuthenticated] = useState(ApiService.isLoggedIn());
const [isTeacherAuthenticated, setIsTeacherAuthenticated] = useState(ApiService.isLoggedInTeacher());
const [isRoomRequireAuthentication, setRoomsRequireAuth] = useState(null);
const location = useLocation();
// Check login status every time the route changes
useEffect(() => {
const checkLoginStatus = () => {
setIsAuthenticated(ApiService.isLoggedIn());
setIsTeacherAuthenticated(ApiService.isLoggedInTeacher());
};
const fetchAuthenticatedRooms = async () => {
const data = await ApiService.getRoomsRequireAuth();
setRoomsRequireAuth(data);
};
checkLoginStatus();
fetchAuthenticatedRooms();
}, [location]);
const handleLogout = () => { const handleLogout = () => {
ApiService.logout(); ApiService.logout();
} setIsAuthenticated(false);
setIsTeacherAuthenticated(false);
};
const isLoggedIn = () => {
return ApiService.isLoggedIn();
}
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>
@ -46,22 +64,46 @@ function App() {
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
{/* Pages espace enseignant */} {/* Pages espace enseignant */}
<Route path="/teacher/login" element={<Login />} /> <Route
<Route path="/teacher/register" element={<Register />} /> path="/teacher/dashboard"
<Route path="/teacher/resetPassword" element={<ResetPassword />} /> element={isTeacherAuthenticated ? <Dashboard /> : <Navigate to="/login" />}
<Route path="/teacher/dashboard" element={<Dashboard />} /> />
<Route path="/teacher/share/:id" element={<Share />} /> <Route
<Route path="/teacher/editor-quiz/:id" element={<QuizForm />} /> path="/teacher/share/:id"
<Route path="/teacher/manage-room/:id" element={<ManageRoom />} /> element={isTeacherAuthenticated ? <Share /> : <Navigate to="/login" />}
/>
<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 path="/student/join-room" element={<JoinRoom />} /> <Route
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

@ -0,0 +1,17 @@
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

@ -0,0 +1,17 @@
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

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

View file

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

View file

@ -0,0 +1,17 @@
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.length).toBe(0); expect(user.answers).toHaveLength(0);
}); });
}); });

View file

@ -3,49 +3,87 @@ 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', () => {
test('renders error message when questions contain invalid syntax', () => { it('renders error message when questions contain invalid syntax', () => {
render(<GIFTTemplatePreview questions={['Invalid GIFT syntax']} />); render(<GIFTTemplatePreview questions={['T{']} hideAnswers={false} />);
const errorMessage = screen.findByText(/Erreur inconnue/i, {}, { timeout: 5000 });
expect(errorMessage).resolves.toBeInTheDocument();
});
test('renders preview when valid questions are provided', () => {
const questions = [
'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();
const errorMessage = previewContainer.querySelector('div[label="error-message"]');
expect(errorMessage).toBeInTheDocument();
}); });
test('hides answers when hideAnswers prop is true', () => {
const questions = [ it('renders preview when valid questions are provided, including answers, has no errors', () => {
'Question 1 { A | B | C }', render(<GIFTTemplatePreview questions={validQuestions} hideAnswers={false} />);
'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();
// Check that all question titles are rendered inside the previewContainer
validQuestions.forEach((question) => {
const title = question.split('::')[1].split('::')[0];
expect(previewContainer).toHaveTextContent(title);
}); });
// it('renders images correctly', () => { // There should be no errors
// const questions = [ const errorMessage = previewContainer.querySelector('div[label="error-message"]');
// 'Question 1', expect(errorMessage).not.toBeInTheDocument();
// '<img src="image1.jpg" alt="Image 1">', // Check that some stems and answers are rendered inside the previewContainer
// 'Question 2', expect(previewContainer).toHaveTextContent('Troo statement');
// '<img src="image2.jpg" alt="Image 2">', expect(previewContainer).toHaveTextContent('What is the answer?');
// ]; expect(previewContainer).toHaveTextContent('MultiChoice question?');
// const { getByAltText } = render(<GIFTTemplatePreview questions={questions} />); expect(previewContainer).toHaveTextContent('Vrai');
// const image1 = getByAltText('Image 1'); // short answers are stored in a textbox
// const image2 = getByAltText('Image 2'); const answerInputElements = screen.getAllByRole('textbox');
// expect(image1).toBeInTheDocument(); const giftInputElements = answerInputElements.filter(element => element.classList.contains('gift-input'));
// expect(image2).toBeInTheDocument();
// }); expect(giftInputElements).toHaveLength(1);
// it('renders non-images correctly', () => { expect(giftInputElements[0]).toHaveAttribute('placeholder', 'ShortAnswerOne, ShortAnswerTwo');
// const questions = ['Question 1', 'Question 2'];
// const { queryByAltText } = render(<GIFTTemplatePreview questions={questions} />); // Check for correct answer icon just after MQAnswerOne
// const image1 = queryByAltText('Image 1'); const mqAnswerOneElement = screen.getByText('MQAnswerOne');
// const image2 = queryByAltText('Image 2'); const correctAnswerIcon = mqAnswerOneElement.parentElement?.querySelector('[data-testid="correct-icon"]');
// expect(image1).toBeNull(); expect(correctAnswerIcon).toBeInTheDocument();
// expect(image2).toBeNull();
// }); // 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();
});
it('should indicate in the preview that unsupported GIFT questions are not supported', () => {
render(<GIFTTemplatePreview questions={unsupportedQuestions} hideAnswers={false} />);
const previewContainer = screen.getByTestId('preview-container');
expect(previewContainer).toBeInTheDocument();
// find all unsupported errors (should be 4)
const unsupportedMessages = previewContainer.querySelectorAll('div[label="error-message"]');
expect(unsupportedMessages).toHaveLength(4);
});
}); });

View file

@ -0,0 +1,95 @@
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

@ -0,0 +1,110 @@
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

@ -0,0 +1,95 @@
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

@ -0,0 +1,55 @@
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

@ -0,0 +1,51 @@
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: true, formattedFeedback: { format: 'plain' , text: 'Correct!'}, weight: 1 } { formattedText: { format: 'plain', text: 'Choice 2' }, isCorrect: false, formattedFeedback: { format: 'plain' , text: 'Correct!'}, weight: 0 }
], ],
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 style=" &lt;svg data-testid="correct-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="correct-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="correct-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="correct-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="correct-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="correct-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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: 1em; width: 0.75em;
color: hsl(120, 39%, 54%); color: hsl(2, 64%, 58%);
" 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; " 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;
&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 style=" &lt;svg data-testid="correct-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="correct-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="correct-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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 style=" &lt;svg data-testid="correct-icon" 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 style=" &lt;svg data-testid="incorrect-icon" 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

@ -0,0 +1,147 @@
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

@ -0,0 +1,44 @@
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

@ -0,0 +1,178 @@
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,23 +5,33 @@ 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[]; }
const question = questions[0]; ::Sample Question 2:: Question stem
{
=Choice 1
=Choice 2
~Choice 3
}
`) as MultipleChoiceQuestion[];
const questionWithOneCorrectChoice = questions[0];
const questionWithMultipleCorrectChoices = questions[1];
describe('MultipleChoiceQuestionDisplay', () => { describe('MultipleChoiceQuestionDisplay', () => {
const mockHandleOnSubmitAnswer = jest.fn(); const mockHandleOnSubmitAnswer = jest.fn();
const TestWrapper = ({ showAnswer }: { showAnswer: boolean }) => { const TestWrapper = ({ showAnswer, question }: { showAnswer: boolean; question: MultipleChoiceQuestion }) => {
const [showAnswerState, setShowAnswerState] = useState(showAnswer); const [showAnswerState, setShowAnswerState] = useState(showAnswer);
const handleOnSubmitAnswer = (answer: string) => { const handleOnSubmitAnswer = (answer: AnswerType) => {
mockHandleOnSubmitAnswer(answer); mockHandleOnSubmitAnswer(answer);
setShowAnswerState(true); setShowAnswerState(true);
}; };
@ -37,28 +47,51 @@ describe('MultipleChoiceQuestionDisplay', () => {
); );
}; };
const choices = question.choices; const twoChoices = questionWithOneCorrectChoice.choices;
const threeChoices = questionWithMultipleCorrectChoices.choices;
beforeEach(() => { test('renders a question (that has only one correct choice) and its choices', () => {
render(<TestWrapper showAnswer={false} />); render(<TestWrapper showAnswer={false} question={questionWithOneCorrectChoice} />);
});
test('renders the question and choices', () => { expect(screen.getByText(questionWithOneCorrectChoice.formattedStem.text)).toBeInTheDocument();
expect(screen.getByText(question.formattedStem.text)).toBeInTheDocument(); twoChoices.forEach((choice) => {
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(() => {
@ -69,10 +102,68 @@ 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');
@ -97,7 +188,8 @@ describe('MultipleChoiceQuestionDisplay', () => {
expect(wrongAnswer1?.textContent).toContain('❌'); expect(wrongAnswer1?.textContent).toContain('❌');
}); });
it('should not show ✅ or ❌ when repondre button is not clicked', async () => { it('should not show ✅ or ❌ when Répondre 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');

View file

@ -67,6 +67,7 @@ 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', () => {
@ -77,6 +78,7 @@ describe('NumericalQuestion Component', () => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith(7); expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith([7]);
mockHandleOnSubmitAnswer.mockClear();
}); });
}); });

View file

@ -29,23 +29,24 @@ 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);
@ -73,7 +74,8 @@ 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', () => {
@ -93,7 +95,8 @@ 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', () => {
@ -117,7 +120,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,6 +47,7 @@ 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', () => {
@ -60,6 +61,7 @@ describe('ShortAnswerQuestion Component', () => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith('User Input'); expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(['User Input']);
mockHandleSubmitAnswer.mockClear();
}); });
}); });

View file

@ -5,6 +5,7 @@ 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();
@ -16,7 +17,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: boolean) => { const handleOnSubmitAnswer = (answer: AnswerType) => {
mockHandleSubmitAnswer(answer); mockHandleSubmitAnswer(answer);
setShowAnswerState(true); setShowAnswerState(true);
}; };
@ -55,6 +56,7 @@ 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', () => {
@ -69,7 +71,8 @@ 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', () => {
@ -82,7 +85,8 @@ describe('TrueFalseQuestion Component', () => {
fireEvent.click(submitButton); fireEvent.click(submitButton);
}); });
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(false); expect(mockHandleSubmitAnswer).toHaveBeenCalledWith([false]);
mockHandleSubmitAnswer.mockClear();
}); });
@ -111,7 +115,7 @@ describe('TrueFalseQuestion Component', () => {
expect(wrongAnswer1?.textContent).toContain('❌'); expect(wrongAnswer1?.textContent).toContain('❌');
}); });
it('should not show ✅ or ❌ when repondre button is not clicked', async () => { it('should not show ✅ or ❌ when pondre 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

@ -0,0 +1,81 @@
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

@ -17,6 +17,7 @@ describe('StudentWaitPage Component', () => {
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', () => {
@ -39,5 +40,4 @@ describe('StudentWaitPage Component', () => {
expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(screen.getByRole('dialog')).toBeInTheDocument();
}); });
});
})

View file

@ -0,0 +1,359 @@
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

@ -0,0 +1,382 @@
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,9 +5,10 @@ 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} `::Sample Question 1:: Sample Question 1 {=Option A =Option B ~Option C}
::Sample Question 2:: Sample Question 2 {T}`); ::Sample Question 2:: Sample Question 2 {T}`);
@ -26,10 +27,12 @@ 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', () => {
@ -48,7 +51,50 @@ 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 () => {
@ -70,11 +116,33 @@ describe('StudentModeQuiz', () => {
fireEvent.click(screen.getByText('Question suivante')); fireEvent.click(screen.getByText('Question suivante'));
}); });
const sampleQuestionElements = screen.queryAllByText(/Sample question 2/i); expect(screen.getByText('Sample Question 2')).toBeInTheDocument();
expect(sampleQuestionElements.length).toBeGreaterThan(0); expect(screen.getByText('Répondre')).toBeInTheDocument();
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,41 +3,52 @@ 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 { MultipleChoiceQuestion, parse } from 'gift-pegjs'; import { BaseQuestion, 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 { mock } from 'node:test'; import { QuestionType } from 'src/Types/QuestionType';
import { AnswerSubmissionToBackendType } from 'src/services/WebsocketService';
const mockGiftQuestions = parse( const mockGiftQuestions = parse(
`::Sample Question:: Sample Question {=Option A ~Option B}`); `::Sample Question 1:: Sample Question 1 {=Option A ~Option B}
::Sample Question 2:: Sample Question 2 {=Option A ~Option B}`);
describe('TeacherModeQuiz', () => { const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
it ('renders the initial question as MultipleChoiceQuestion', () => { if (question.type !== "Category")
expect(mockGiftQuestions[0].type).toBe('MC'); question.id = (index + 1).toString();
const newMockQuestion = question;
return {question : newMockQuestion as BaseQuestion};
}); });
const mockQuestion = mockGiftQuestions[0] as MultipleChoiceQuestion; describe('TeacherModeQuiz', () => {
let mockQuestion = mockQuestions[0].question 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 () => {
render( const utils = 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')).toBeInTheDocument(); expect(screen.getByText('Sample Question 1')).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();
@ -52,8 +63,52 @@ 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);
expect(screen.getByText('Votre réponse est "Option A".')).toBeInTheDocument(); mockSubmitAnswer.mockClear();
});
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', () => {

View file

@ -0,0 +1,95 @@
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

@ -0,0 +1,42 @@
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,7 +1,9 @@
//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');
@ -23,13 +25,13 @@ describe('WebSocketService', () => {
}); });
test('connect should initialize socket connection', () => { test('connect should initialize socket connection', () => {
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL); WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_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_SOCKET_URL); mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
expect(WebsocketService['socket']).toBeTruthy(); expect(WebsocketService['socket']).toBeTruthy();
WebsocketService.disconnect(); WebsocketService.disconnect();
expect(mockSocket.disconnect).toHaveBeenCalled(); expect(mockSocket.disconnect).toHaveBeenCalled();
@ -37,17 +39,24 @@ describe('WebSocketService', () => {
}); });
test('createRoom should emit create-room event', () => { test('createRoom should emit create-room event', () => {
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL); const roomName = 'Test Room';
WebsocketService.createRoom(); WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
expect(mockSocket.emit).toHaveBeenCalledWith('create-room'); WebsocketService.createRoom(roomName);
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 question = { id: 1, text: 'Sample Question' }; const mockGiftQuestions = parse('A {T}');
const mockQuestions: QuestionType[] = mockGiftQuestions.map((question, index) => {
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL); if (question.type !== "Category")
WebsocketService.nextQuestion(roomName, question); question.id = (index + 1).toString();
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 });
}); });
@ -55,7 +64,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_SOCKET_URL); mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_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,
@ -66,7 +75,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_SOCKET_URL); mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.endQuiz(roomName); WebsocketService.endQuiz(roomName);
expect(mockSocket.emit).toHaveBeenCalledWith('end-quiz', { roomName }); expect(mockSocket.emit).toHaveBeenCalledWith('end-quiz', { roomName });
}); });
@ -75,7 +84,7 @@ describe('WebSocketService', () => {
const enteredRoomName = 'testRoom'; const enteredRoomName = 'testRoom';
const username = 'testUser'; const username = 'testUser';
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL); mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_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

@ -0,0 +1,42 @@
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 = "2+2 \\= 4 ? {T}\n// Utilisez les valeurs {T}, {F}, {TRUE} \net {FALSE}."; const QuestionVraiFaux = "::Exemple de question vrai/faux:: \n 2+2 \\= 4 ? {T} //Utilisez les valeurs {T}, {F}, {TRUE} et {FALSE}.";
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 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 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 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 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 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 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}"; 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}";
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. Question numérique </h4> <h4> 5. Questions numériques </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 } from './templates'; import Template, { ErrorTemplate, UnsupportedQuestionTypeError } from './templates';
import { parse } from 'gift-pegjs'; import { parse } from 'gift-pegjs';
import './styles.css'; import './styles.css';
import DOMPurify from 'dompurify'; import { FormattedTextTemplate } from './templates/TextTypeTemplate';
interface GIFTTemplatePreviewProps { interface GIFTTemplatePreviewProps {
questions: string[]; questions: string[];
@ -22,19 +22,6 @@ 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], {
@ -42,11 +29,15 @@ const GIFTTemplatePreview: React.FC<GIFTTemplatePreviewProps> = ({
theme: 'light' theme: 'light'
}); });
} catch (error) { } catch (error) {
if (error instanceof Error) { let errorMsg: string;
previewHTML += ErrorTemplate(giftQuestion + '\n' + error.message); if (error instanceof UnsupportedQuestionTypeError) {
errorMsg = ErrorTemplate(giftQuestion, `Erreur: ${error.message}`);
} else if (error instanceof Error) {
errorMsg = ErrorTemplate(giftQuestion, `Erreur GIFT: ${error.message}`);
} else { } else {
previewHTML += ErrorTemplate(giftQuestion + '\n' + 'Erreur inconnue'); errorMsg = ErrorTemplate(giftQuestion, 'Erreur inconnue');
} }
previewHTML += `<div label="error-message">${errorMsg}</div>`;
} }
}); });
@ -74,7 +65,8 @@ 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'); const errorItemDark = ErrorTemplate('Hello', 'Error');
const lightItems = multiple.map((item) => Template(item, { theme: 'light' })).join(''); const lightItems = multiple.map((item) => Template(item, { theme: 'light' })).join('');
const errorItem = ErrorTemplate('Hello'); const errorItem = ErrorTemplate('Hello', 'Error');
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 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 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>`;
}; };
const IncorrectIcon = (): string => { const IncorrectIcon = (): string => {
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 `<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 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 (text: string): string { export default function (questionText: string, errorText: string): string {
const Container = ` const Container = `
flex-wrap: wrap; flex-wrap: wrap;
position: relative; position: relative;
@ -13,47 +13,49 @@ export default function (text: 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
.split(/\r?\n/) // // CPF: disabled the following regex because it's not clear what it's supposed to do
.map((category) => // // .split(/\r?\n/)
category.replace(/(^[ \\t]+)?(((^|\n)\s*[$]CATEGORY:))(.+)/g, `<br><b>$5</b><br>`) // // .map((category) =>
) // // 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((openBracket) => openBracket.replace(/([^\\]|^){([#])?/g, `$1<br>`)) // // .map((title) => title.replace(/\s*(::)\s*(.*?)(::)/g, `<br><b>$2</b><br>`))
.map((closeBracket) => closeBracket.replace(/([^\\]|^)}/g, `$1<br>`)) // // .map((openBracket) => openBracket.replace(/([^\\]|^){([#])?/g, `$1<br>`))
.join(''); // // .map((closeBracket) => closeBracket.replace(/([^\\]|^)}/g, `$1<br>`))
} // // .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 isMultipleAnswer = choices.filter(({ isCorrect }) => isCorrect === true).length === 0; const hasManyCorrectChoices = choices.filter(({ isCorrect }) => isCorrect === true).length > 1;
const prompt = `<span style="${ParagraphStyle(state.theme)}">Choisir une réponse${ const prompt = `<span style="${ParagraphStyle(state.theme)}">Choisir une réponse${
isMultipleAnswer ? ` ou plusieurs` : `` hasManyCorrectChoices ? ` 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 = isMultipleAnswer ? isPositiveWeight : isCorrect; const isCorrectOption = hasManyCorrectChoices ? isPositiveWeight || isCorrect : 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="${
isMultipleAnswer ? 'checkbox' : 'radio' hasManyCorrectChoices ? '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,14 +4,24 @@ 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.
export function formatLatex(text: string): string { 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,20 +6,32 @@ import {
MultipleChoiceQuestion as MultipleChoiceType, MultipleChoiceQuestion as MultipleChoiceType,
NumericalQuestion as NumericalType, NumericalQuestion as NumericalType,
ShortAnswerQuestion as ShortAnswerType, ShortAnswerQuestion as ShortAnswerType,
// Essay as EssayType, // EssayQuestion 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' };
@ -54,23 +66,21 @@ export default function Template(
// case 'Matching': // case 'Matching':
// return Matching({ ...(keys as MatchingType) }); // return Matching({ ...(keys as MatchingType) });
default: default:
// TODO: throw error for unsupported question types? // convert type to human-readable string
// throw new Error(`Unsupported question type: ${type}`); throw new UnsupportedQuestionTypeError(type); }
return ``;
}
} }
export function ErrorTemplate(text: string, options?: Partial<DisplayOptions>): string { export function ErrorTemplate(questionText: string, errorText: string, options?: Partial<DisplayOptions>): string {
Object.assign(state, options); Object.assign(state, options);
return Error(text); return Error(questionText, errorText);
} }
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,10 +1,11 @@
import { useNavigate } from 'react-router-dom'; import { Link, 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;
} }
@ -20,7 +21,7 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, handleLogout }) => {
onClick={() => navigate('/')} onClick={() => navigate('/')}
/> />
{isLoggedIn() && ( {isLoggedIn && (
<Button <Button
variant="outlined" variant="outlined"
color="primary" color="primary"
@ -28,10 +29,19 @@ const Header: React.FC<HeaderProps> = ({ isLoggedIn, handleLogout }) => {
handleLogout(); handleLogout();
navigate('/'); navigate('/');
}} }}
startIcon={<ExitToAppIcon />}
> >
Logout Déconnexion
</Button> </Button>
)} )}
{!isLoggedIn && (
<div className="auth-selection-btn">
<Link to="/login">
<button className="auth-btn">Connexion</button>
</Link>
</div>
)}
</div> </div>
); );
}; };

View file

@ -0,0 +1,309 @@
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

@ -0,0 +1,55 @@
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}>
Annuler <div>Annuler</div>
</Button> </Button>
<Button variant="contained" onClick={launchQuiz}> <Button variant="contained" onClick={launchQuiz}>
Lancer <div>Lancer</div>
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View file

@ -1,26 +1,16 @@
// LiveResults.tsx // LiveResults.tsx
import React, { useMemo, useState } from 'react'; import React, { 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;
@ -30,241 +20,14 @@ 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>
@ -295,146 +58,14 @@ 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;
return ( <LiveResultsTable
<TableCell students={students}
key={index} questions={questions}
sx={{ showCorrectAnswers={showCorrectAnswers}
textAlign: 'center', showSelectedQuestion={showSelectedQuestion}
borderStyle: 'solid', showUsernames={showUsernames}
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

@ -0,0 +1,75 @@
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

@ -0,0 +1,79 @@
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

@ -0,0 +1,94 @@
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

@ -0,0 +1,50 @@
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,27 +4,64 @@ 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: string) => void; handleOnSubmitAnswer?: (answer: AnswerType) => void;
showAnswer?: boolean; showAnswer?: boolean;
passedAnswer?: AnswerType;
} }
const MultipleChoiceQuestionDisplay: React.FC<Props> = (props) => { const MultipleChoiceQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer } = props; const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = props;
const [answer, setAnswer] = useState<string>(); console.log('MultipleChoiceQuestionDisplay: passedAnswer', JSON.stringify(passedAnswer));
const [answer, setAnswer] = useState<AnswerType>(() => {
if (passedAnswer && passedAnswer.length > 0) {
return passedAnswer;
}
return [];
});
let disableButton = false;
if (handleOnSubmitAnswer === undefined) {
disableButton = true;
}
useEffect(() => { useEffect(() => {
setAnswer(undefined); console.log('MultipleChoiceQuestionDisplay: passedAnswer', JSON.stringify(passedAnswer));
}, [question]); if (passedAnswer !== undefined) {
setAnswer(passedAnswer);
} else {
setAnswer([]);
}
}, [passedAnswer, question.id]);
const handleOnClickAnswer = (choice: string) => { const handleOnClickAnswer = (choice: string) => {
setAnswer(choice); setAnswer((prevAnswer) => {
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">
@ -32,47 +69,61 @@ 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) => {
const selected = answer === choice.formattedText.text ? 'selected' : ''; console.log(`answer: ${answer}, choice: ${choice.formattedText.text}`);
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"
onClick={() => !showAnswer && handleOnClickAnswer(choice.formattedText.text)}> disabled={disableButton}
{showAnswer? (<div> {(choice.isCorrect ? '✅' : '❌')}</div>) onClick={() => !showAnswer && handleOnClickAnswer(choice.formattedText.text)}
:``} >
{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 dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(choice.formattedText) }} /> <div
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 dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(choice.formattedFeedback) }} /> <div
dangerouslySetInnerHTML={{
__html: FormattedTextTemplate(choice.formattedFeedback),
}}
/>
</div> </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 dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.formattedGlobalFeedback) }} /> <div
dangerouslySetInnerHTML={{
__html: FormattedTextTemplate(question.formattedGlobalFeedback),
}}
/>
</div> </div>
)} )}
{!showAnswer && handleOnSubmitAnswer && ( {!showAnswer && handleOnSubmitAnswer && (
<Button <Button
variant="contained" variant="contained"
onClick={() => onClick={() =>
answer !== undefined && handleOnSubmitAnswer && handleOnSubmitAnswer(answer) answer.length > 0 && handleOnSubmitAnswer && handleOnSubmitAnswer(answer)
} }
disabled={answer === undefined} disabled={answer.length === 0}
> >
Répondre Répondre
</Button> </Button>
)} )}
</div> </div>

View file

@ -1,26 +1,32 @@
// NumericalQuestion.tsx // NumericalQuestion.tsx
import React, { useState } from 'react'; import React, { useEffect, 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: number) => void; handleOnSubmitAnswer?: (answer: AnswerType) => void;
showAnswer?: boolean; showAnswer?: boolean;
passedAnswer?: AnswerType;
} }
const NumericalQuestionDisplay: React.FC<Props> = (props) => { const NumericalQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer } = const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } =
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])) {
@ -44,10 +50,16 @@ const NumericalQuestionDisplay: React.FC<Props> = (props) => {
</div> </div>
{showAnswer ? ( {showAnswer ? (
<> <>
<div className="correct-answer-text mb-2">{correctAnswer}</div> <div className="correct-answer-text mb-2">
<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>}
</> </>
) : ( ) : (
<> <>
@ -57,7 +69,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' }}
/> />
@ -75,7 +87,7 @@ const NumericalQuestionDisplay: React.FC<Props> = (props) => {
handleOnSubmitAnswer && handleOnSubmitAnswer &&
handleOnSubmitAnswer(answer) handleOnSubmitAnswer(answer)
} }
disabled={answer === undefined || isNaN(answer)} disabled={answer === undefined || answer === null || isNaN(answer[0] as number)}
> >
Répondre Répondre
</Button> </Button>

View file

@ -5,17 +5,21 @@ 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: string | number | boolean) => void; handleOnSubmitAnswer?: (answer: AnswerType) => 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(() => {
@ -30,37 +34,32 @@ 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':
@ -69,6 +68,7 @@ const QuestionDisplay: React.FC<QuestionProps> = ({
question={question} question={question}
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
showAnswer={showAnswer} showAnswer={showAnswer}
passedAnswer={answer}
/> />
); );
break; break;

View file

@ -1,18 +1,29 @@
import React, { useState } from 'react'; import React, { useEffect, 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: string) => void; handleOnSubmitAnswer?: (answer: AnswerType) => 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 [answer, setAnswer] = useState<string>(); const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } = props;
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">
@ -22,11 +33,18 @@ 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) }} />
@ -40,7 +58,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"
@ -54,7 +72,7 @@ const ShortAnswerQuestionDisplay: React.FC<Props> = (props) => {
handleOnSubmitAnswer && handleOnSubmitAnswer &&
handleOnSubmitAnswer(answer) handleOnSubmitAnswer(answer)
} }
disabled={answer === undefined || answer === ''} disabled={answer === null || answer === undefined || answer.length === 0}
> >
Répondre Répondre
</Button> </Button>

View file

@ -4,21 +4,45 @@ 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: boolean) => void; handleOnSubmitAnswer?: (answer: AnswerType) => void;
showAnswer?: boolean; showAnswer?: boolean;
passedAnswer?: AnswerType;
} }
const TrueFalseQuestionDisplay: React.FC<Props> = (props) => { const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
const { question, showAnswer, handleOnSubmitAnswer } = const { question, showAnswer, handleOnSubmitAnswer, passedAnswer } =
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);
if (passedAnswer && (passedAnswer[0] === true || passedAnswer[0] === false)) {
setAnswer(passedAnswer[0]);
} else {
setAnswer(undefined); setAnswer(undefined);
}, [question]); }
}, [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' : '';
@ -30,35 +54,36 @@ const TrueFalseQuestionDisplay: React.FC<Props> = (props) => {
<div className="choices-wrapper mb-1"> <div className="choices-wrapper mb-1">
<Button <Button
className="button-wrapper" className="button-wrapper"
onClick={() => !showAnswer && setAnswer(true)} onClick={() => !showAnswer && handleOnClickAnswer(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>
</Button>
<Button
className="button-wrapper"
onClick={() => !showAnswer && setAnswer(false)}
fullWidth
>
{showAnswer? (<div> {(!question.isTrue ? '✅' : '❌')}</div>):``}
<div className={`circle ${selectedFalse}`}>F</div>
<div className={`answer-text ${selectedFalse}`}>Faux</div>
</Button>
</div>
{/* selected TRUE, show True feedback if it exists */}
{showAnswer && answer && question.trueFormattedFeedback && ( {showAnswer && answer && question.trueFormattedFeedback && (
<div className="true-feedback mb-2"> <div className="true-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.trueFormattedFeedback) }} /> <div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.trueFormattedFeedback) }} />
</div> </div>
)} )}
{/* selected FALSE, show False feedback if it exists */} </Button>
<Button
className="button-wrapper"
onClick={() => !showAnswer && handleOnClickAnswer(false)}
fullWidth
disabled={disableButton}
>
{showAnswer ? (<div> {(!question.isTrue ? '✅' : '❌')}</div>) : ``}
<div className={`answer-text ${selectedFalse}`}>Faux</div>
{showAnswer && !answer && question.falseFormattedFeedback && ( {showAnswer && !answer && question.falseFormattedFeedback && (
<div className="false-feedback mb-2"> <div className="false-feedback mb-2">
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.falseFormattedFeedback) }} /> <div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate(question.falseFormattedFeedback) }} />
</div> </div>
)} )}
</Button>
</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) }} />
@ -68,7 +93,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,7 +27,6 @@
} }
.question-wrapper .katex { .question-wrapper .katex {
display: block;
text-align: center; text-align: center;
} }
@ -120,9 +119,9 @@
} }
.feedback-container { .feedback-container {
margin-left: 1.1rem; display: inline-block !important; /* override the parent */
display: inline-flex !important; /* override the parent */
align-items: center; align-items: center;
margin-left: 1.1rem;
position: relative; position: relative;
padding: 0 0.5rem; padding: 0 0.5rem;
background-color: hsl(43, 100%, 94%); background-color: hsl(43, 100%, 94%);
@ -148,6 +147,25 @@
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

@ -0,0 +1,94 @@
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,41 +3,47 @@ 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[];
submitAnswer: (answer: string | number | boolean, idQuestion: number) => void; answers: AnswerSubmissionToBackendType[];
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 [imageUrl, setImageUrl] = useState(''); // const [answer, setAnswer] = useState<AnswerType>('');
// const previousQuestion = () => {
// setQuestion(questions[Number(questionInfos.question?.id) - 2]);
// setIsAnswerSubmitted(false);
// };
useEffect(() => {}, [questionInfos]); const previousQuestion = () => {
setQuestion(questions[Number(questionInfos.question?.id) - 2]);
};
useEffect(() => {
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: string | number | boolean) => { const handleOnSubmitAnswer = (answer: AnswerType) => {
const idQuestion = Number(questionInfos.question.id) || -1; const idQuestion = Number(questionInfos.question.id) || -1;
submitAnswer(answer, idQuestion); submitAnswer(answer, idQuestion);
setIsAnswerSubmitted(true); setIsAnswerSubmitted(true);
@ -46,11 +52,13 @@ 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">
@ -66,25 +74,24 @@ 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 className="center-h-align mt-1/2"> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', marginTop: '1rem' }}>
<div className="w-12"> <div>
{/* <Button <Button
variant="outlined" variant="outlined"
onClick={previousQuestion} onClick={previousQuestion}
fullWidth fullWidth
startIcon={<ChevronLeft />}
disabled={Number(questionInfos.question.id) <= 1} disabled={Number(questionInfos.question.id) <= 1}
> >
Question précédente Question précédente
</Button> */} </Button>
</div> </div>
<div className="w-12"> <div>
<Button style={{ display: isAnswerSubmitted ? 'block' : 'none' }} <Button
variant="outlined" variant="outlined"
onClick={nextQuestion} onClick={nextQuestion}
fullWidth fullWidth
//endIcon={<ChevronRight />}
disabled={Number(questionInfos.question.id) >= questions.length} disabled={Number(questionInfos.question.id) >= questions.length}
> >
Question suivante Question suivante

View file

@ -9,21 +9,24 @@ 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={() => setIsDialogOpen(true)} onClick={handleLaunchClick}
startIcon={<PlayArrow />} startIcon={<PlayArrow />}
fullWidth sx={{ fontWeight: 600, fontSize: 20, width: 'auto' }}
sx={{ fontWeight: 600, fontSize: 20 }}
> >
Lancer Lancer
</Button> </Button>

View file

@ -1,38 +1,59 @@
// 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;
submitAnswer: (answer: string | number | boolean, idQuestion: number) => void; answers: AnswerSubmissionToBackendType[];
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 [feedbackMessage, setFeedbackMessage] = useState(''); const [answer, setAnswer] = useState<AnswerType>();
// 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(() => {
setIsAnswerSubmitted(false); console.log(`TeacherModeQuiz: useEffect: isAnswerSubmitted: ${isAnswerSubmitted}`);
}, [questionInfos]); setIsFeedbackDialogOpen(isAnswerSubmitted);
}, [isAnswerSubmitted]);
const handleOnSubmitAnswer = (answer: string | number | boolean) => { const handleOnSubmitAnswer = (answer: AnswerType) => {
const idQuestion = Number(questionInfos.question.id) || -1; const idQuestion = Number(questionInfos.question.id) || -1;
submitAnswer(answer, idQuestion); submitAnswer(answer, idQuestion);
setFeedbackMessage(`Votre réponse est "${answer.toString()}".`); // setAnswer(answer);
setIsFeedbackDialogOpen(true); setIsFeedbackDialogOpen(true);
}; };
@ -65,6 +86,7 @@ const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
<QuestionComponent <QuestionComponent
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
question={questionInfos.question as Question} question={questionInfos.question as Question}
answer={answer}
/> />
)} )}
@ -74,16 +96,27 @@ const TeacherModeQuiz: React.FC<TeacherModeQuizProps> = ({
> >
<DialogTitle>Rétroaction</DialogTitle> <DialogTitle>Rétroaction</DialogTitle>
<DialogContent> <DialogContent>
{feedbackMessage} <div style={{
wordWrap: 'break-word',
whiteSpace: 'pre-wrap',
maxHeight: '400px',
overflowY: 'auto',
}}>
<div style={{ textAlign: 'left', fontWeight: 'bold', marginTop: '10px' }}
>Question : </div>
</div>
<QuestionComponent <QuestionComponent
handleOnSubmitAnswer={handleOnSubmitAnswer} handleOnSubmitAnswer={handleOnSubmitAnswer}
question={questionInfos.question as Question} question={questionInfos.question as Question}
showAnswer={true} showAnswer={true}
answer={answer}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={handleFeedbackDialogClose} color="primary"> <Button onClick={handleFeedbackDialogClose} color="primary">
OK Fermer
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>

View file

@ -1,11 +1,11 @@
// constants.tsx // constants.tsx
const ENV_VARIABLES = { const ENV_VARIABLES = {
MODE: 'production', MODE: process.env.MODE || "production",
VITE_BACKEND_URL: import.meta.env.VITE_BACKEND_URL || "", VITE_BACKEND_URL: process.env.VITE_BACKEND_URL || "",
VITE_BACKEND_SOCKET_URL: import.meta.env.VITE_BACKEND_SOCKET_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 || '',
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

@ -0,0 +1,61 @@
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

@ -0,0 +1,49 @@
.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

@ -0,0 +1,27 @@
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

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

View file

@ -0,0 +1,114 @@
// 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

@ -0,0 +1,68 @@
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

@ -0,0 +1,23 @@
.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

@ -0,0 +1,17 @@
.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,6 +61,25 @@
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

@ -13,18 +13,35 @@ 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(''); const [username, setUsername] = useState(ApiService.getUsername());
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();
@ -33,23 +50,41 @@ const JoinRoom: React.FC = () => {
}; };
}, []); }, []);
const handleCreateSocket = () => { useEffect(() => {
console.log(`JoinRoom: handleCreateSocket: ${ENV_VARIABLES.VITE_BACKEND_SOCKET_URL}`); console.log(`JoinRoom: useEffect: questions: ${JSON.stringify(questions)}`);
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL); setAnswers(questions ? Array(questions.length).fill({} as AnswerSubmissionToBackendType) : []);
}, [questions]);
socket.on('join-success', () => {
const handleCreateSocket = () => {
console.log(`JoinRoom: handleCreateSocket: ${ENV_VARIABLES.VITE_BACKEND_URL}`);
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
socket.on('join-success', (roomJoinedName) => {
setIsWaitingForTeacher(true); setIsWaitingForTeacher(true);
setIsConnecting(false); setIsConnecting(false);
console.log('Successfully joined the room.'); console.log(`on(join-success): Successfully joined the room ${roomJoinedName}`);
}); });
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]);
}); });
@ -78,6 +113,7 @@ const JoinRoom: React.FC = () => {
}; };
const disconnect = () => { const disconnect = () => {
// localStorage.clear();
webSocketService.disconnect(); webSocketService.disconnect();
setSocket(null); setSocket(null);
setQuestion(undefined); setQuestion(undefined);
@ -96,21 +132,37 @@ 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: string | number | boolean, idQuestion: number) => { const handleOnSubmitAnswer = (answer: AnswerType, 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); webSocketService.submitAnswer(answerData);
}; };
const handleReturnKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && username && roomName) {
handleSocket();
}
};
if (isWaitingForTeacher) { if (isWaitingForTeacher) {
return ( return (
<div className='room'> <div className='room'>
@ -139,6 +191,7 @@ const JoinRoom: React.FC = () => {
return ( return (
<StudentModeQuiz <StudentModeQuiz
questions={questions} questions={questions}
answers={answers}
submitAnswer={handleOnSubmitAnswer} submitAnswer={handleOnSubmitAnswer}
disconnectWebSocket={disconnect} disconnectWebSocket={disconnect}
/> />
@ -148,6 +201,7 @@ const JoinRoom: React.FC = () => {
question && ( question && (
<TeacherModeQuiz <TeacherModeQuiz
questionInfos={question} questionInfos={question}
answers={answers}
submitAnswer={handleOnSubmitAnswer} submitAnswer={handleOnSubmitAnswer}
disconnectWebSocket={disconnect} disconnectWebSocket={disconnect}
/> />
@ -156,20 +210,25 @@ const JoinRoom: React.FC = () => {
default: default:
return ( return (
<LoginContainer <LoginContainer
title='Rejoindre une salle' title={isQRCodeJoin ? `Rejoindre la salle ${roomName}` : 'Rejoindre une salle'}
error={connectionError}> error={connectionError}
>
{/* Afficher champ salle SEULEMENT si pas de QR code */}
{!isQRCodeJoin && (
<TextField <TextField
type="number" type="text"
label="Numéro de la salle" label="Nom de la salle"
variant="outlined" variant="outlined"
value={roomName} value={roomName}
onChange={(e) => setRoomName(e.target.value)} onChange={(e) => setRoomName(e.target.value.toUpperCase())}
placeholder="Numéro de la salle" placeholder="Nom de la salle"
sx={{ marginBottom: '1rem' }} sx={{ marginBottom: '1rem' }}
fullWidth fullWidth={true}
onKeyDown={handleReturnKey}
/> />
)}
{/* Champ username toujours visible */}
<TextField <TextField
label="Nom d'utilisateur" label="Nom d'utilisateur"
variant="outlined" variant="outlined"
@ -177,7 +236,8 @@ 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 fullWidth={true}
onKeyDown={handleReturnKey}
/> />
<LoadingButton <LoadingButton
@ -185,9 +245,10 @@ const JoinRoom: React.FC = () => {
onClick={handleSocket} onClick={handleSocket}
variant="contained" variant="contained"
sx={{ marginBottom: `${connectionError && '2rem'}` }} sx={{ marginBottom: `${connectionError && '2rem'}` }}
disabled={!username || !roomName} disabled={!username || (isQRCodeJoin && !roomName)}
>Rejoindre</LoadingButton> >
{isQRCodeJoin ? 'Rejoindre avec QR Code' : 'Rejoindre'}
</LoadingButton>
</LoginContainer> </LoginContainer>
); );
} }

View file

@ -12,8 +12,13 @@ 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,
@ -23,6 +28,7 @@ import {
NativeSelect, NativeSelect,
CardContent, CardContent,
styled, styled,
DialogContentText
} from '@mui/material'; } from '@mui/material';
import { import {
Search, Search,
@ -31,11 +37,10 @@ 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)({
@ -43,7 +48,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 = () => {
@ -53,6 +58,14 @@ 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 =>
@ -65,7 +78,6 @@ 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]) {
@ -77,28 +89,83 @@ const Dashboard: React.FC = () => {
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
if (!ApiService.isLoggedIn()) { const isLoggedIn = await ApiService.isLoggedIn();
navigate("/teacher/login"); console.log(`Dashboard: isLoggedIn: ${isLoggedIn}`);
if (!isLoggedIn) {
navigate('/teacher/login');
return; return;
} } else {
else { const userRooms = await ApiService.getUserRooms();
const userFolders = await ApiService.getUserFolders(); setRooms(userRooms as RoomType[]);
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")
@ -109,33 +176,29 @@ 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 {
else { console.log('show some quizzes');
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 = folders.find((folder) => folder._id === selectedFolderId)?.title || ''; const folderTitle =
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?');
@ -149,30 +212,27 @@ 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 {
else { console.log('show some quizzes');
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);
@ -181,7 +241,6 @@ const Dashboard: React.FC = () => {
const handleOnImport = () => { const handleOnImport = () => {
setShowImportModal(true); setShowImportModal(true);
}; };
const validateQuiz = (questions: string[]) => { const validateQuiz = (questions: string[]) => {
@ -193,11 +252,10 @@ 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;
} }
} }
@ -214,7 +272,6 @@ 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);
@ -222,7 +279,6 @@ 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) {
@ -232,18 +288,17 @@ 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);
} }
@ -253,7 +308,10 @@ 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('Entrée le nouveau nom du fichier', folders.find((folder) => folder._id === selectedFolderId)?.title); const newTitle = prompt(
'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);
@ -290,124 +348,261 @@ 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) => {
navigate(`/teacher/manage-room/${quiz._id}`); if (selectedRoom) {
navigate(`/teacher/manage-room/${quiz._id}/${selectedRoom.title}`);
} else {
const randomSixDigit = Math.floor(100000 + Math.random() * 900000);
navigate(`/teacher/manage-room/${quiz._id}/${randomSixDigit}`);
} }
};
const handleShareQuiz = async (quiz: QuizType) => {
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"> <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>
<div className="title">Tableau de bord</div> {/* Sélecteur de salle */}
<div
className="roomSelection"
style={{ display: 'flex', justifyContent: 'flex-end', gap: '15px' }}
>
<select
value={selectedRoom?._id || ''}
onChange={(e) => handleSelectRoom(e)}
id="room-select"
style={{
padding: '8px 12px',
fontSize: '14px',
borderRadius: '8px',
border: '1px solid #ccc',
backgroundColor: '#fff',
maxWidth: '200px',
cursor: 'pointer',
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 className="search-bar"> {/* Dialog pour créer une salle */}
<Dialog open={openAddRoomDialog} onClose={() => setOpenAddRoomDialog(false)}>
<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
id="select-folder"
color="primary"
value={selectedFolderId}
onChange={handleSelectFolder}
sx={{
padding: '6px 12px',
maxWidth: '180px',
borderRadius: '8px',
borderColor: '#e0e0e0',
'&:hover': { borderColor: '#5271FF' }
}}
>
<option value="">Tous les dossiers...</option>
{folders.map((folder) => (
<option value={folder._id} key={folder._id}>
{folder.title}
</option>
))}
</NativeSelect>
</div>
<div className="actions">
<Tooltip title="Ajouter dossier" placement="top">
<IconButton color="primary" onClick={handleCreateFolder}>
{' '}
<Add />{' '}
</IconButton>
</Tooltip>
<Tooltip title="Renommer dossier" placement="top">
<div>
<IconButton
color="primary"
onClick={handleRenameFolder}
disabled={selectedFolderId == ''} // cannot action on all
>
{' '}
<Edit />{' '}
</IconButton>
</div>
</Tooltip>
<Tooltip title="Dupliquer dossier" placement="top">
<div>
<IconButton
color="primary"
onClick={handleDuplicateFolder}
disabled={selectedFolderId == ''} // cannot action on all
>
{' '}
<FolderCopy />{' '}
</IconButton>
</div>
</Tooltip>
<Tooltip title="Supprimer dossier" placement="top">
<div>
<IconButton
aria-label="delete"
color="primary"
onClick={handleDeleteFolder}
disabled={selectedFolderId == ''} // cannot action on all
>
{' '}
<DeleteOutline />{' '}
</IconButton>
</div>
</Tooltip>
</div>
</div>
<div
className="search-bar"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
gap: '20px',
width: '100%'
}}
>
<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 <TextField
onChange={handleSearch} onChange={handleSearch}
value={searchTerm} value={searchTerm}
placeholder="Rechercher un quiz par son titre" placeholder="Rechercher un quiz"
fullWidth fullWidth
autoFocus
sx={{
borderRadius: '8px',
border: '1px solid #ccc',
padding: '8px 12px',
backgroundColor: '#fff',
fontWeight: 500,
width: '100%',
maxWidth: '1000px'
}}
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (
<InputAdornment position="end"> <InputAdornment position="end">
<IconButton> <IconButton
onClick={toggleSearchVisibility}
sx={{
borderRadius: '8px',
border: '1px solid #ccc',
backgroundColor: '#fff',
color: '#5271FF'
}}
>
<Search /> <Search />
</IconButton> </IconButton>
</InputAdornment> </InputAdornment>
) )
}} }}
/> />
)}
</div> </div>
<div className='folder'> {/* À droite : les boutons */}
<div className='select'> <div style={{ display: 'flex', gap: '12px' }}>
<NativeSelect
id="select-folder"
color="primary"
value={selectedFolderId}
onChange={handleSelectFolder}
>
<option value=""> Tous les dossiers... </option>
{folders.map((folder: FolderType) => (
<option value={folder._id} key={folder._id}> {folder.title} </option>
))}
</NativeSelect>
</div>
<div className='actions'>
<Tooltip title="Ajouter dossier" placement="top">
<IconButton
color="primary"
onClick={handleCreateFolder}
> <Add /> </IconButton>
</Tooltip>
<Tooltip title="Renommer dossier" placement="top">
<IconButton
color="primary"
onClick={handleRenameFolder}
disabled={selectedFolderId == ''} // cannot action on all
> <Edit /> </IconButton>
</Tooltip>
<Tooltip title="Dupliquer dossier" placement="top">
<IconButton
color="primary"
onClick={handleDuplicateFolder}
disabled={selectedFolderId == ''} // cannot action on all
> <FolderCopy /> </IconButton>
</Tooltip>
<Tooltip title="Supprimer dossier" placement="top">
<IconButton
aria-label="delete"
color="primary"
onClick={handleDeleteFolder}
disabled={selectedFolderId == ''} // cannot action on all
> <DeleteOutline /> </IconButton>
</Tooltip>
</div>
</div>
<div className='ajouter'>
<Button <Button
variant="outlined" variant="outlined"
color="primary" color="primary"
startIcon={<Add />} startIcon={<Add />}
onClick={handleCreateQuiz} onClick={handleCreateQuiz}
sx={{ borderRadius: '8px', minWidth: 'auto', padding: '4px 12px' }}
> >
Ajouter un nouveau quiz Nouveau quiz
</Button> </Button>
<Button <Button
@ -416,26 +611,33 @@ const Dashboard: React.FC = () => {
startIcon={<Upload />} startIcon={<Upload />}
onClick={handleOnImport} onClick={handleOnImport}
> >
Import Importer
</Button> </Button>
</div> </div>
<div className='list'> </div>
{Object.keys(quizzesByFolder).map(folderName => (
<CustomCard key={folderName} className='folder-card'> <div className="list">
<div className='folder-tab'>{folderName}</div> {Object.keys(quizzesByFolder).map((folderName) => (
<CustomCard key={folderName} className="folder-card">
<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="Lancer quiz" placement="top"> <Tooltip title="Démarrer" 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} (${
quiz.content.length
} question${
quiz.content.length > 1 ? 's' : ''
})`}
</Button> </Button>
</div>
</Tooltip> </Tooltip>
</div> </div>
@ -444,33 +646,38 @@ const Dashboard: React.FC = () => {
<DownloadQuizModal quiz={quiz} /> <DownloadQuizModal quiz={quiz} />
</div> </div>
<Tooltip title="Modifier quiz" placement="top"> <Tooltip title="Modifier" placement="top">
<IconButton <IconButton
color="primary" color="primary"
onClick={() => handleEditQuiz(quiz)} onClick={() => handleEditQuiz(quiz)}
> <Edit /> </IconButton> >
{' '}
<Edit />{' '}
</IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Dupliquer quiz" placement="top"> <Tooltip title="Dupliquer" 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 quiz" placement="top"> <Tooltip title="Supprimer" placement="top">
<IconButton <IconButton
aria-label="delete" aria-label="delete"
color="primary" color="error"
onClick={() => handleRemoveQuiz(quiz)} onClick={() => handleRemoveQuiz(quiz)}
> <DeleteOutline /> </IconButton> >
</Tooltip> {' '}
<DeleteOutline />{' '}
<Tooltip title="Partager quiz" placement="top"> </IconButton>
<IconButton
color="primary"
onClick={() => handleShareQuiz(quiz)}
> <Share /> </IconButton>
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
@ -485,7 +692,6 @@ const Dashboard: React.FC = () => {
handleOnImport={handleOnImport} handleOnImport={handleOnImport}
selectedFolder={selectedFolderId} selectedFolder={selectedFolderId}
/> />
</div> </div>
); );
}; };
@ -498,4 +704,3 @@ 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, useRef, CSSProperties } from 'react'; import React, { useState, useEffect, 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,14 +9,15 @@ 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, Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material'; import { Button, TextField, NativeSelect, Divider } 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 { Upload } from '@mui/icons-material'; import { ENV_VARIABLES } from 'src/constants';
interface EditQuizParams { interface EditQuizParams {
id: string; id: string;
@ -38,8 +39,6 @@ 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 = () => {
@ -118,7 +117,11 @@ const QuizForm: React.FC = () => {
setValue(value); setValue(value);
} }
const linesArray = value.split(/(?<=^|[^\\]}.*)[\n]+/); // split value when there is at least one blank line
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();
@ -162,87 +165,75 @@ 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);
} }
return ( const handleCopyImage = (id: string) => {
<div className='quizEditor'> const escLink = `${ENV_VARIABLES.BACKEND_URL}/api/image/get/${id}`;
setImageLinks(prevLinks => [...prevLinks, escLink]);
}
<div className='editHeader'> return (
<div className="quizEditor">
<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?`}
/> />
<div className='title'>Éditeur de quiz</div> <Button
variant="contained"
onClick={handleQuizSave}
sx={{ display: 'flex', alignItems: 'center' }}
>
<SaveIcon sx={{ fontSize: 20 }} />
Enregistrer
</Button>
</div>
<div className='dumb'></div> <div style={{ textAlign: 'center', marginTop: '30px' }}>
<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}
color="primary"
placeholder="Titre du quiz" placeholder="Titre du quiz"
label="Titre du quiz" label="Titre du quiz"
fullWidth 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=""> 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></label> </NativeSelect>
</div>
<Button variant="contained" onClick={handleQuizSave}>
Enregistrer
</Button>
<Divider style={{ margin: '16px 0' }} /> <Divider style={{ margin: '16px 0' }} />
@ -255,37 +246,11 @@ const QuizForm: React.FC = () => {
onEditorChange={handleUpdatePreview} /> onEditorChange={handleUpdatePreview} />
<div className='images'> <div className='images'>
<div className='upload'> <div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<label className="dropArea"> <h4>Mes images :</h4>
<input type="file" id="file-input" className="file-input" <ImageGalleryModal handleCopy={handleCopyImage} />
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>
@ -299,7 +264,7 @@ const QuizForm: React.FC = () => {
</div> </div>
<ul> <ul>
{imageLinks.map((link, index) => { {imageLinks.map((link, index) => {
const imgTag = `![alt_text](${escapeForGIFT(link)} "texte de l'infobulle")`; const imgTag = `[markdown]![alt_text](${escapeForGIFT(link)} "texte de l'infobulle") {T}`;
return ( return (
<li key={index}> <li key={index}>
<code <code

View file

@ -1,6 +1,4 @@
import { useNavigate, Link } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
// JoinRoom.tsx
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import './Login.css'; import './Login.css';
@ -38,6 +36,11 @@ const Login: React.FC = () => {
}; };
const handleReturnKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && email && password) {
login();
}
};
return ( return (
<LoginContainer <LoginContainer
@ -51,7 +54,8 @@ 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 fullWidth={true}
onKeyDown={handleReturnKey} // Add this line as well
/> />
<TextField <TextField
@ -62,7 +66,8 @@ 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 fullWidth={true}
onKeyDown={handleReturnKey} // Add this line as well
/> />
<LoadingButton <LoadingButton

View file

@ -1,69 +1,150 @@
// 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 { ParsedGIFTQuestion, BaseQuestion, parse, Question } from 'gift-pegjs'; import { 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 { QuestionService } from '../../../services/QuestionService'; import webSocketService, {
import webSocketService, { AnswerReceptionFromBackendType } from '../../../services/WebsocketService'; AnswerReceptionFromBackendType
} 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 = useParams<{ id: string }>(); const { quizId = '', roomName = '' } = useParams<{ quizId: string; roomName: 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(() => {
if (quizId.id) { const verifyLogin = async () => {
const fetchquiz = async () => { if (!ApiService.isLoggedIn()) {
navigate('/teacher/login');
return;
}
};
const quiz = await ApiService.getQuiz(quizId.id as string); verifyLogin();
}, []);
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(`Une erreur est survenue.\n Le quiz ${quizId.id} n'a pas été trouvé\nVeuillez réessayer plus tard`) window.alert(
console.error('Quiz not found for id:', quizId.id); `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);
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(`Une erreur est survenue.\n Le quiz ${quizId.id} n'a pas été trouvé\nVeuillez réessayer plus tard`) window.alert(
console.error('Quiz not found for id:', quizId.id); `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);
navigate('/teacher/dashboard'); navigate('/teacher/dashboard');
return; return;
} }
@ -71,76 +152,69 @@ const ManageRoom: React.FC = () => {
const disconnectWebSocket = () => { const disconnectWebSocket = () => {
if (socket) { if (socket) {
webSocketService.endQuiz(roomName); webSocketService.endQuiz(formattedRoomName);
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 = () => {
console.log('Creating WebSocket room...'); const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
setConnectingError(''); const roomNameUpper = roomName.toUpperCase();
const socket = webSocketService.connect(ENV_VARIABLES.VITE_BACKEND_SOCKET_URL); setFormattedRoomName(roomNameUpper);
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(); webSocketService.createRoom(roomNameUpper);
}); });
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) => {
setRoomName(roomName); socket.on('create-success', (createdRoomName: string) => {
}); 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) => {
console.log(`Student joined: name = ${student.name}, id = ${student.id}`); setNewlyConnectedUser(student);
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 user-joined in room ${roomName}`); console.log(`Listening for submit-answer-room in room ${formattedRoomName}`);
// 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(`Received answer from ${username} for question ${idQuestion}: ${answer}`); console.log(
`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;
@ -148,7 +222,6 @@ const ManageRoom: React.FC = () => {
// 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);
@ -159,17 +232,31 @@ const ManageRoom: React.FC = () => {
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((ans) => ans.idQuestion === idQuestion); const existingAnswer = student.answers.find(
(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 ? { ...ans, answer, isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!) } : ans); return ans.idQuestion === idQuestion
? {
...ans,
answer,
isCorrect: checkIfIsCorrect(
answer,
idQuestion,
quizQuestions!
)
}
: ans;
}); });
} else { } else {
// Add a new answer const newAnswer = {
const newAnswer = { idQuestion, answer, isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!) }; idQuestion,
answer,
isCorrect: checkIfIsCorrect(answer, idQuestion, quizQuestions!)
};
updatedAnswers = [...student.answers, newAnswer]; updatedAnswers = [...student.answers, newAnswer];
} }
return { ...student, answers: updatedAnswers }; return { ...student, answers: updatedAnswers };
@ -184,73 +271,8 @@ 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;
@ -259,7 +281,12 @@ 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(roomName, quizQuestions[nextQuestionIndex]); webSocketService.nextQuestion({
roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: nextQuestionIndex,
isLaunch: false
});
}; };
const previousQuestion = () => { const previousQuestion = () => {
@ -269,7 +296,12 @@ const ManageRoom: React.FC = () => {
if (prevQuestionIndex === undefined || prevQuestionIndex < 0) return; if (prevQuestionIndex === undefined || prevQuestionIndex < 0) return;
setCurrentQuestion(quizQuestions[prevQuestionIndex]); setCurrentQuestion(quizQuestions[prevQuestionIndex]);
webSocketService.nextQuestion(roomName, quizQuestions[prevQuestionIndex]); webSocketService.nextQuestion({
roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: prevQuestionIndex,
isLaunch: false
});
}; };
const initializeQuizQuestion = () => { const initializeQuizQuestion = () => {
@ -297,7 +329,12 @@ const ManageRoom: React.FC = () => {
} }
setCurrentQuestion(quizQuestions[0]); setCurrentQuestion(quizQuestions[0]);
webSocketService.nextQuestion(roomName, quizQuestions[0]); webSocketService.nextQuestion({
roomName: formattedRoomName,
questions: quizQuestions,
questionIndex: 0,
isLaunch: true
});
}; };
const launchStudentMode = () => { const launchStudentMode = () => {
@ -309,15 +346,19 @@ const ManageRoom: React.FC = () => {
return; return;
} }
setQuizQuestions(quizQuestions); setQuizQuestions(quizQuestions);
webSocketService.launchStudentModeQuiz(roomName, quizQuestions); webSocketService.launchStudentModeQuiz(formattedRoomName, quizQuestions);
}; };
const launchQuiz = () => { const launchQuiz = () => {
if (!socket || !roomName || !quiz?.content || quiz?.content.length === 0) { setQuizStarted(true);
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(`Error launching quiz. socket: ${socket}, roomName: ${roomName}, quiz: ${quiz}`); console.log(
`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();
@ -329,74 +370,28 @@ 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(roomName, quizQuestions[questionIndex]); webSocketService.nextQuestion({
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');
}; };
function checkIfIsCorrect(answer: string | number | boolean, idQuestion: number, questions: QuestionType[]): boolean { if (!formattedRoomName) {
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 ? (
@ -419,33 +414,114 @@ const ManageRoom: React.FC = () => {
} }
return ( return (
<div className='room'> <div className="room">
<div className='roomHeader'> {/* En-tête avec bouton Disconnect à gauche et QR code à droite */}
<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?`}
/>
<div className='centerTitle'> <Button
<div className='title'>Salle: {roomName}</div> variant="contained"
<div className='userCount subtitle'>Utilisateurs: {students.length}/60</div> color="primary"
onClick={() => setShowQrModal(true)}
startIcon={<QRCodeIcon />}
>
Lien de participation
</Button>
</div> </div>
<div className='dumb'></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>
<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 className="dumb"></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)}
@ -454,12 +530,10 @@ 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}
@ -474,42 +548,51 @@ const ManageRoom: React.FC = () => {
showSelectedQuestion={showSelectedQuestion} showSelectedQuestion={showSelectedQuestion}
students={students} students={students}
></LiveResultsComponent> ></LiveResultsComponent>
</div> </div>
</div> </div>
{quizMode === 'teacher' && ( {quizMode === 'teacher' && (
<div className="questionNavigationButtons" style={{ display: 'flex', justifyContent: 'center' }}> <div
className="questionNavigationButtons"
style={{ display: 'flex', justifyContent: 'center' }}
>
<div className="previousQuestionButton"> <div className="previousQuestionButton">
<Button onClick={previousQuestion} <Button
onClick={previousQuestion}
variant="contained" variant="contained"
disabled={Number(currentQuestion?.question.id) <= 1}> disabled={Number(currentQuestion?.question.id) <= 1}
>
Question précédente Question précédente
</Button> </Button>
</div> </div>
<div className="nextQuestionButton"> <div className="nextQuestionButton">
<Button onClick={nextQuestion} <Button
onClick={nextQuestion}
variant="contained" variant="contained"
disabled={Number(currentQuestion?.question.id) >=quizQuestions.length} disabled={
Number(currentQuestion?.question.id) >=
quizQuestions.length
}
> >
Prochaine question Prochaine question
</Button> </Button>
</div> </div>
</div> )}
</div> </div>
)}
<div className="finishQuizButton">
<Button onClick={finishQuiz} variant="contained">
Terminer le quiz
</Button>
</div>
</div>
) : ( ) : (
<StudentWaitPage <StudentWaitPage
students={students} students={students}
launchQuiz={launchQuiz} launchQuiz={launchQuiz}
setQuizMode={setQuizMode} setQuizMode={setQuizMode}
/> />
)} )}
</div> </div>
</div> </div>
); );
}; };

View file

@ -0,0 +1,59 @@
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,25 +2,31 @@
.room .roomHeader { .room .roomHeader {
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: space-between; align-items: flex-start;
align-content: stretch position: relative;
} }
.room .roomHeader .returnButton { .room .roomHeader .returnButton {
flex-basis: 10%; position: absolute;
top: 10px;
display: flex; left: 0;
justify-content: center; z-index: 10;
} }
.room .roomHeader .centerTitle { .room .roomHeader .centerTitle {
flex-basis: auto; flex-basis: auto;
display: flex; display: flex;
flex-direction: column; justify-content: flex-start;
justify-content: center; align-items: flex-start;
align-items: center; margin-top: 40px;
}
.room .roomHeader .headerContent {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 60px;
} }
.room .roomHeader .dumb { .room .roomHeader .dumb {
@ -34,152 +40,16 @@
overflow: auto; overflow: auto;
justify-content: center; justify-content: center;
/* align-items: center; */
} }
.room .finishQuizButton {
/* .create-room-container {
display: flex; display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
}
.manage-room-container {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
width: 100%;
}
.quiz-setup-container {
display: flex;
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; justify-content: flex-end;
margin-top: 2vh; margin-left: auto;
}
.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%; width: 100%;
} }
.flex-column-wrapper { .room h1 {
display: flex; text-align: center;
flex-direction: column; margin-top: 50px;
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

@ -0,0 +1,161 @@
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,46 +1,47 @@
// 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 } from '@mui/material'; import { Button, NativeSelect, Typography, Box } 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 () => {
console.log("QUIZID : " + id) try {
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;
} }
if (!ApiService.isLoggedIn()) { if (!ApiService.isLoggedIn()) {
window.alert(`Vous n'êtes pas connecté.\nVeuillez vous connecter et revenir à ce lien`); navigate("/login");
navigate("/teacher/login"); return;
}
const quizIds = await ApiService.getAllQuizIds();
if (quizIds.includes(id)) {
setQuizExists(true);
setLoading(false);
return; return;
} }
const userFolders = await ApiService.getUserFolders(); const userFolders = await ApiService.getUserFolders();
if (userFolders.length == 0) { if (userFolders.length == 0) {
window.alert(`Vous n'avez aucun dossier.\nVeuillez en créer un et revenir à ce lien`)
navigate('/teacher/dashboard'); navigate('/teacher/dashboard');
return; return;
} }
@ -50,17 +51,22 @@ const Share: React.FC = () => {
const title = await ApiService.getSharedQuiz(id); const title = await ApiService.getSharedQuiz(id);
if (!title) { if (!title) {
window.alert(`Une erreur est survenue.\n Veuillez 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;
} }
setQuizTitle(title); setQuizTitle(title);
setLoading(false);
} catch (error) {
console.error('Error fetching data:', error);
setLoading(false);
navigate('/teacher/dashboard');
}
}; };
fetchData(); fetchData();
}, []); }, [id, navigate]);
const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedFolder(event.target.value); setSelectedFolder(event.target.value);
@ -68,14 +74,12 @@ 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;
@ -85,47 +89,90 @@ 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 ( return (
<div className='quizImport'> <div className='quizImport'>
<div className='importHeader'> <div className='importHeader'>
<ReturnButton /> <ReturnButton />
<div className='titleContainer'>
<div className='title'>Importer quiz: {quizTitle}</div> <div className='mainTitle'>Quiz déjà existant</div>
</div>
<div className='dumb'></div> <div className='dumb'></div>
</div> </div>
<div className='editSection'> <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>
<div> <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 (
<div className='quizImport'>
<div className='importHeader'>
<ReturnButton />
<div className='titleContainer'>
<div className='mainTitle'>Importation du 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>
<div className='editSection'>
<div className='formContainer'>
<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}> <Button variant="contained" onClick={handleQuizSave} className="saveButton">
{<SaveIcon sx={{ fontSize: 20, marginRight: '8px' }} />}
Enregistrer Enregistrer
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
); );
}; };

View file

@ -3,19 +3,58 @@
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 .title { .quizImport .importHeader .titleContainer {
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 */
}

View file

@ -1,8 +1,11 @@
import axios, { AxiosError, AxiosResponse } from 'axios'; import axios, { AxiosError, AxiosResponse } from 'axios';
import { jwtDecode } from 'jwt-decode';
import { ENV_VARIABLES } from '../constants';
import { FolderType } from 'src/Types/FolderType'; import { FolderType } from 'src/Types/FolderType';
import { ImagesResponse, ImagesParams } from '../Types/Images';
import { QuizType } from 'src/Types/QuizType'; import { QuizType } from 'src/Types/QuizType';
import { ENV_VARIABLES } from 'src/constants'; import { RoomType } from 'src/Types/RoomType';
type ApiResponse = boolean | string; type ApiResponse = boolean | string;
@ -34,7 +37,7 @@ class ApiService {
} }
// Helpers // Helpers
private saveToken(token: string): void { public saveToken(token: string): void {
const now = new Date(); const now = new Date();
const object = { const object = {
@ -78,7 +81,93 @@ class ApiService {
return true; return true;
} }
public isLoggedInTeacher(): boolean {
const token = this.getToken();
if (token == null) {
return false;
}
try {
const decodedToken = jwtDecode(token) as { roles: string[] };
/////// REMOVE BELOW
// automatically add teacher role if not present
if (!decodedToken.roles.includes('teacher')) {
decodedToken.roles.push('teacher');
}
////// REMOVE ABOVE
const userRoles = decodedToken.roles;
const requiredRole = 'teacher';
if (!userRoles || !userRoles.includes(requiredRole)) {
return false;
}
// Update token expiry
this.saveToken(token);
return true;
} catch (error) {
console.error("Error decoding token:", error);
return false;
}
}
public saveUsername(username: string): void {
if (!username || username.length === 0) {
return;
}
const object = {
username: username
}
localStorage.setItem("username", JSON.stringify(object));
}
public getUsername(): string {
const objectStr = localStorage.getItem("username");
if (!objectStr) {
return "";
}
const object = JSON.parse(objectStr)
return object.username;
}
public getUserID(): string {
const token = localStorage.getItem("jwt");
if (!token) {
return "";
}
const jsonObj = jwtDecode(token) as { userId: string };
if (!jsonObj.userId) {
return "";
}
return jsonObj.userId;
}
// Route to know if rooms need authentication to join
public async getRoomsRequireAuth(): Promise<any> {
const url: string = this.constructRequestUrl(`/auth/getRoomsRequireAuth`);
const result: AxiosResponse = await axios.get(url);
if (result.status == 200) {
return result.data.roomsRequireAuth;
}
return false;
}
public logout(): void { public logout(): void {
localStorage.removeItem("username");
return localStorage.removeItem("jwt"); return localStorage.removeItem("jwt");
} }
@ -88,27 +177,31 @@ class ApiService {
* @returns true if successful * @returns true if successful
* @returns A error string if unsuccessful, * @returns A error string if unsuccessful,
*/ */
public async register(email: string, password: string): Promise<ApiResponse> { public async register(name: string, email: string, password: string, roles: string[]): Promise<any> {
console.log(`ApiService.register: name: ${name}, email: ${email}, password: ${password}, roles: ${roles}`);
try { try {
if (!email || !password) { if (!email || !password) {
throw new Error(`L'email et le mot de passe sont requis.`); throw new Error(`L'email et le mot de passe sont requis.`);
} }
const url: string = this.constructRequestUrl(`/user/register`); const url: string = this.constructRequestUrl(`/auth/simple-auth/register`);
const headers = this.constructRequestHeaders(); const headers = this.constructRequestHeaders();
const body = { email, password }; const body = { name, email, password, roles };
const result: AxiosResponse = await axios.post(url, body, { headers: headers }); const result: AxiosResponse = await axios.post(url, body, { headers: headers });
if (result.status !== 200) { if (result.status == 200) {
throw new Error(`L'enregistrement a échoué. Status: ${result.status}`); //window.location.href = result.request.responseURL;
window.location.href = '/login';
}
else {
throw new Error(`La connexion a échoué. Status: ${result.status}`);
} }
return true; return true;
} catch (error) { } catch (error) {
console.log("Error details: ", error);
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const err = error as AxiosError; const err = error as AxiosError;
@ -122,47 +215,57 @@ class ApiService {
/** /**
* @returns true if successful * @returns true if successful
* @returns A error string if unsuccessful, * @returns An error string if unsuccessful
*/ */
public async login(email: string, password: string): Promise<ApiResponse> { public async login(email: string, password: string): Promise<any> {
console.log(`login: email: ${email}, password: ${password}`);
try { try {
if (!email || !password) { if (!email || !password) {
throw new Error(`L'email et le mot de passe sont requis.`); throw new Error("L'email et le mot de passe sont requis.");
} }
const url: string = this.constructRequestUrl(`/user/login`); const url: string = this.constructRequestUrl(`/auth/simple-auth/login`);
const headers = this.constructRequestHeaders(); const headers = this.constructRequestHeaders();
const body = { email, password }; const body = { email, password };
console.log(`login: POST ${url} body: ${JSON.stringify(body)}`);
const result: AxiosResponse = await axios.post(url, body, { headers: headers }); const result: AxiosResponse = await axios.post(url, body, { headers: headers });
console.log(`login: result: ${result.status}, ${result.data}`);
if (result.status !== 200) { // If login is successful, redirect the user
throw new Error(`La connexion a échoué. Status: ${result.status}`); if (result.status === 200) {
} //window.location.href = result.request.responseURL;
this.saveToken(result.data.token); this.saveToken(result.data.token);
this.saveUsername(result.data.username);
window.location.href = '/teacher/dashboard';
return true; return true;
} else {
throw new Error(`La connexion a échoué. Statut: ${result.status}`);
}
} catch (error) { } catch (error) {
console.log("Error details:", error); console.log("Error details:", error);
console.log("axios.isAxiosError(error): ", axios.isAxiosError(error)); // Handle Axios-specific errors
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
const err = error as AxiosError; const err = error as AxiosError;
if (err.status === 401) { const responseData = err.response?.data as { message?: string } | undefined;
return 'Email ou mot de passe incorrect.';
} // If there is a message field in the response, print it
const data = err.response?.data as { error: string } | undefined; if (responseData?.message) {
return data?.error || 'Erreur serveur inconnue lors de la requête.'; console.log("Backend error message:", responseData.message);
return responseData.message;
} }
return `Une erreur inattendue s'est produite.` // If no message is found, return a fallback message
return "Erreur serveur inconnue lors de la requête.";
}
// Handle other non-Axios errors
return "Une erreur inattendue s'est produite.";
} }
} }
/** /**
* @returns true if successful * @returns true if successful
* @returns A error string if unsuccessful, * @returns A error string if unsuccessful,
@ -174,7 +277,7 @@ class ApiService {
throw new Error(`L'email est requis.`); throw new Error(`L'email est requis.`);
} }
const url: string = this.constructRequestUrl(`/user/reset-password`); const url: string = this.constructRequestUrl(`/auth/simple-auth/reset-password`);
const headers = this.constructRequestHeaders(); const headers = this.constructRequestHeaders();
const body = { email }; const body = { email };
@ -210,7 +313,7 @@ class ApiService {
throw new Error(`L'email, l'ancien et le nouveau mot de passe sont requis.`); throw new Error(`L'email, l'ancien et le nouveau mot de passe sont requis.`);
} }
const url: string = this.constructRequestUrl(`/user/change-password`); const url: string = this.constructRequestUrl(`/auth/simple-auth/change-password`);
const headers = this.constructRequestHeaders(); const headers = this.constructRequestHeaders();
const body = { email, oldPassword, newPassword }; const body = { email, oldPassword, newPassword };
@ -461,7 +564,6 @@ class ApiService {
const headers = this.constructRequestHeaders(); const headers = this.constructRequestHeaders();
const body = { folderId }; const body = { folderId };
console.log(headers);
const result: AxiosResponse = await axios.post(url, body, { headers: headers }); const result: AxiosResponse = await axios.post(url, body, { headers: headers });
if (result.status !== 200) { if (result.status !== 200) {
@ -751,36 +853,6 @@ class ApiService {
} }
} }
async ShareQuiz(quizId: string, email: string): Promise<ApiResponse> {
try {
if (!quizId || !email) {
throw new Error(`quizId and email are required.`);
}
const url: string = this.constructRequestUrl(`/quiz/Share`);
const headers = this.constructRequestHeaders();
const body = { quizId, email };
const result: AxiosResponse = await axios.put(url, body, { headers: headers });
if (result.status !== 200) {
throw new Error(`Update and share quiz failed. Status: ${result.status}`);
}
return true;
} catch (error) {
console.log("Error details: ", error);
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const data = err.response?.data as { error: string } | undefined;
return data?.error || 'Unknown server error during request.';
}
return `An unexpected error occurred.`;
}
}
async getSharedQuiz(quizId: string): Promise<string> { async getSharedQuiz(quizId: string): Promise<string> {
try { try {
if (!quizId) { if (!quizId) {
@ -840,6 +912,195 @@ class ApiService {
} }
} }
//ROOM routes
public async getUserRooms(): Promise<RoomType[] | string> {
try {
const url: string = this.constructRequestUrl(`/room/getUserRooms`);
const headers = this.constructRequestHeaders();
const result: AxiosResponse = await axios.get(url, { headers: headers });
if (result.status !== 200) {
throw new Error(`L'obtention des salles utilisateur a échoué. Status: ${result.status}`);
}
return result.data.data.map((room: RoomType) => ({ _id: room._id, title: room.title }));
} catch (error) {
console.log("Error details: ", error);
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const data = err.response?.data as { error: string } | undefined;
const url = err.config?.url || 'URL inconnue';
return data?.error || `Erreur serveur inconnue lors de la requête (${url}).`;
}
return `Une erreur inattendue s'est produite.`
}
}
public async getRoomContent(roomId: string): Promise<RoomType> {
try {
const url = this.constructRequestUrl(`/room/${roomId}`);
const headers = this.constructRequestHeaders();
const response = await axios.get<{ data: RoomType }>(url, { headers });
if (response.status !== 200) {
throw new Error(`Failed to get room: ${response.status}`);
}
return response.data.data;
} catch (error) {
if (axios.isAxiosError(error)) {
const serverError = error.response?.data?.error;
throw new Error(serverError || 'Erreur serveur inconnue');
}
throw new Error('Erreur réseau');
}
}
public async getRoomTitleByUserId(userId: string): Promise<string[] | string> {
try {
if (!userId) {
throw new Error(`L'ID utilisateur est requis.`);
}
const url: string = this.constructRequestUrl(`/room/getRoomTitleByUserId/${userId}`);
const headers = this.constructRequestHeaders();
const result: AxiosResponse = await axios.get(url, { headers });
if (result.status !== 200) {
throw new Error(`L'obtention des titres des salles a échoué. Status: ${result.status}`);
}
return result.data.titles;
} catch (error) {
console.log("Error details: ", error);
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const data = err.response?.data as { error: string } | undefined;
return data?.error || 'Erreur serveur inconnue lors de la requête.';
}
return `Une erreur inattendue s'est produite.`;
}
}
public async getRoomTitle(roomId: string): Promise<string | string> {
try {
if (!roomId) {
throw new Error(`L'ID de la salle est requis.`);
}
const url: string = this.constructRequestUrl(`/room/getRoomTitle/${roomId}`);
const headers = this.constructRequestHeaders();
const result: AxiosResponse = await axios.get(url, { headers });
if (result.status !== 200) {
throw new Error(`L'obtention du titre de la salle a échoué. Status: ${result.status}`);
}
return result.data.title;
} catch (error) {
console.log("Error details: ", error);
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const data = err.response?.data as { error: string } | undefined;
return data?.error || 'Erreur serveur inconnue lors de la requête.';
}
return `Une erreur inattendue s'est produite.`;
}
}
public async createRoom(title: string): Promise<string> {
try {
if (!title) {
throw new Error("Le titre de la salle est requis.");
}
const url: string = this.constructRequestUrl(`/room/create`);
const headers = this.constructRequestHeaders();
const body = { title };
const result = await axios.post<{ roomId: string }>(url, body, { headers });
return `Salle créée avec succès. ID de la salle: ${result.data.roomId}`;
} catch (error) {
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const serverMessage = (err.response?.data as { message?: string })?.message
|| (err.response?.data as { error?: string })?.error
|| err.message;
if (err.response?.status === 409) {
throw new Error(serverMessage);
}
throw new Error(serverMessage || "Erreur serveur inconnue");
}
throw error;
}
}
public async deleteRoom(roomId: string): Promise<string | string> {
try {
if (!roomId) {
throw new Error(`L'ID de la salle est requis.`);
}
const url: string = this.constructRequestUrl(`/room/delete/${roomId}`);
const headers = this.constructRequestHeaders();
const result: AxiosResponse = await axios.delete(url, { headers });
if (result.status !== 200) {
throw new Error(`La suppression de la salle a échoué. Status: ${result.status}`);
}
return `Salle supprimée avec succès.`;
} catch (error) {
console.log("Error details: ", error);
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const data = err.response?.data as { error: string } | undefined;
return data?.error || 'Erreur serveur inconnue lors de la suppression de la salle.';
}
return `Une erreur inattendue s'est produite.`;
}
}
public async renameRoom(roomId: string, newTitle: string): Promise<string | string> {
try {
if (!roomId || !newTitle) {
throw new Error(`L'ID de la salle et le nouveau titre sont requis.`);
}
const url: string = this.constructRequestUrl(`/room/rename`);
const headers = this.constructRequestHeaders();
const body = { roomId, newTitle };
const result: AxiosResponse = await axios.put(url, body, { headers });
if (result.status !== 200) {
throw new Error(`La mise à jour du titre de la salle a échoué. Status: ${result.status}`);
}
return `Titre de la salle mis à jour avec succès.`;
} catch (error) {
console.log("Error details: ", error);
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const data = err.response?.data as { error: string } | undefined;
return data?.error || 'Erreur serveur inconnue lors de la mise à jour du titre.';
}
return `Une erreur inattendue s'est produite.`;
}
}
// Images Route // Images Route
/** /**
@ -886,7 +1147,126 @@ class ApiService {
return `ERROR : Une erreur inattendue s'est produite.` return `ERROR : Une erreur inattendue s'est produite.`
} }
} }
// NOTE : Get Image pas necessaire
public async getImages(page: number, limit: number): Promise<ImagesResponse> {
try {
const url: string = this.constructRequestUrl(`/image/getImages`);
const headers = this.constructRequestHeaders();
let params : ImagesParams = { page: page, limit: limit };
const result: AxiosResponse = await axios.get(url, { params: params, headers: headers });
if (result.status !== 200) {
throw new Error(`L'affichage des images a échoué. Status: ${result.status}`);
}
const images = result.data;
return images;
} catch (error) {
console.log("Error details: ", error);
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const data = err.response?.data as { error: string } | undefined;
const msg = data?.error || 'Erreur serveur inconnue lors de la requête.';
throw new Error(`L'enregistrement a échoué. Status: ${msg}`);
}
throw new Error(`ERROR : Une erreur inattendue s'est produite.`);
}
}
public async getUserImages(page: number, limit: number): Promise<ImagesResponse> {
try {
const url: string = this.constructRequestUrl(`/image/getUserImages`);
const headers = this.constructRequestHeaders();
let params : ImagesParams = { page: page, limit: limit };
const uid = this.getUserID();
if(uid !== ''){
params.uid = uid;
}
const result: AxiosResponse = await axios.get(url, { params: params, headers: headers });
if (result.status !== 200) {
throw new Error(`L'affichage des images de l'utilisateur a échoué. Status: ${result.status}`);
}
const images = result.data;
return images;
} catch (error) {
console.log("Error details: ", error);
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const data = err.response?.data as { error: string } | undefined;
const msg = data?.error || 'Erreur serveur inconnue lors de la requête.';
throw new Error(`L'enregistrement a échoué. Status: ${msg}`);
}
throw new Error(`ERROR : Une erreur inattendue s'est produite.`);
}
}
public async deleteImage(imgId: string): Promise<ApiResponse> {
try {
const url: string = this.constructRequestUrl(`/image/delete`);
const headers = this.constructRequestHeaders();
const uid = this.getUserID();
let params = { uid: uid, imgId: imgId };
const result: AxiosResponse = await axios.delete(url, { params: params, headers: headers });
if (result.status !== 200) {
throw new Error(`La suppression de l'image a échoué. Status: ${result.status}`);
}
const deleted = result.data.deleted;
return deleted;
} catch (error) {
console.log("Error details: ", error);
if (axios.isAxiosError(error)) {
const err = error as AxiosError;
const data = err.response?.data as { error: string } | undefined;
const msg = data?.error || 'Erreur serveur inconnue lors de la requête.';
throw new Error(`L'enregistrement a échoué. Status: ${msg}`);
}
throw new Error(`ERROR : Une erreur inattendue s'est produite.`);
}
}
public async getAllQuizIds(): Promise<string[]> {
try {
const folders = await this.getUserFolders();
const allQuizIds: string[] = [];
if (Array.isArray(folders)) {
for (const folder of folders) {
const folderQuizzes = await this.getFolderContent(folder._id);
if (Array.isArray(folderQuizzes)) {
allQuizIds.push(...folderQuizzes.map(quiz => quiz._id));
}
}
} else {
console.error('Failed to get user folders:', folders);
}
return allQuizIds;
} catch (error) {
console.error('Failed to get all quiz ids:', error);
throw error;
}
}
} }

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