Merge pull request #45 from louis-antoine-etsmtl/main

init dev branch
This commit is contained in:
louis-antoine-etsmtl 2024-04-07 13:17:28 -04:00 committed by GitHub
commit e2c8a09494
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
192 changed files with 29125 additions and 3 deletions

26
.github/workflows/backend-deploy.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: CI/CD Pipeline for Backend
on:
push:
branches: [ main ]
jobs:
build_and_push_backend:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker image for Backend
uses: docker/build-push-action@v2
with:
context: ./server
file: ./server/Dockerfile
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_BACKEND_REPO }}:latest

26
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: CI/CD Pipeline for Nginx Router
on:
push:
branches: [ main ]
jobs:
build_and_push_nginx:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker image for Router
uses: docker/build-push-action@v2
with:
context: ./nginx
file: ./nginx/Dockerfile
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_ROUTER_REPO }}:latest

View file

@ -0,0 +1,26 @@
name: CI/CD Pipeline for Backend
on:
push:
branches: [ dev ]
jobs:
build_and_push_backend:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker image for Backend
uses: docker/build-push-action@v2
with:
context: ./server
file: ./server/Dockerfile
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/dev_${{ secrets.DOCKERHUB_BACKEND_REPO }}:latest

26
.github/workflows/dev_deploy.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: CI/CD Pipeline for Nginx Router
on:
push:
branches: [ dev ]
jobs:
build_and_push_nginx:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker image for Router
uses: docker/build-push-action@v2
with:
context: ./nginx
file: ./nginx/Dockerfile
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/dev_${{ secrets.DOCKERHUB_ROUTER_REPO }}:latest

View file

@ -0,0 +1,26 @@
name: CI/CD Pipeline for Frontend
on:
push:
branches: [ dev ]
jobs:
build_and_push_frontend:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker image for Frontend
uses: docker/build-push-action@v2
with:
context: ./client
file: ./client/Dockerfile
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/dev_${{ secrets.DOCKERHUB_FRONTEND_REPO }}:latest

26
.github/workflows/frontend-deploy.yml vendored Normal file
View file

@ -0,0 +1,26 @@
name: CI/CD Pipeline for Frontend
on:
push:
branches: [ main ]
jobs:
build_and_push_frontend:
runs-on: ubuntu-latest
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: Log in to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push Docker image for Frontend
uses: docker/build-push-action@v2
with:
context: ./client
file: ./client/Dockerfile
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/${{ secrets.DOCKERHUB_FRONTEND_REPO }}:latest

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024 louis-antoine-etsmtl
Copyright (c) 2023 ETS-PFE004-Plateforme-sondage-minitest
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
SOFTWARE.

View file

@ -1,4 +1,4 @@
# 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.

2
client/.dockerignore Normal file
View file

@ -0,0 +1,2 @@
**/node_modules
.env

2
client/.env.example Normal file
View file

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

18
client/.eslintrc.cjs Normal file
View file

@ -0,0 +1,18 @@
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 },
],
},
}

7
client/.prettierrc.json Normal file
View file

@ -0,0 +1,7 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"tabWidth": 4,
"singleQuote": true,
"printWidth": 100,
"trailingComma": "none"
}

17
client/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
# client
FROM node:18 AS build
WORKDIR /usr/src/app/client
COPY ./package*.json ./
COPY ./ .
RUN npm install
RUN npm run build
EXPOSE 5173
CMD [ "npm", "run", "preview" ]

3
client/babel.config.cjs Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
presets: ['@babel/preset-env', '@babel/preset-react', '@babel/preset-typescript']
};

19
client/index.html Normal file
View file

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/katex@latest/dist/katex.min.css"
crossorigin="anonymous"
/>
<title>Évalue Ton Savoir</title>
<base href="/" />
</head>
<body>
<div id="root"></div>
<script type="module" src="src/main.tsx"></script>
</body>
</html>

17
client/jest.config.cjs Normal file
View file

@ -0,0 +1,17 @@
/** @type {import('ts-jest').JestConfigWithTsJest} */
module.exports = {
roots: ['<rootDir>/src'],
transform: {
'^.+\\.(ts|tsx)$': 'ts-jest',
'^.+\\.(js|jsx)$': 'babel-jest'
},
testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.tsx?$',
testEnvironment: 'jsdom',
//moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
setupFiles: ['./jest.setup.cjs'],
moduleNameMapper: {
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'
},
transformIgnorePatterns: ['node_modules/(?!nanoid/)']
};

7
client/jest.setup.cjs Normal file
View file

@ -0,0 +1,7 @@
global.import = {
meta: {
env: {
VITE_BACKEND_URL: 'https://ets-glitch-backend.glitch.me/'
}
}
};

11129
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

66
client/package.json Normal file
View file

@ -0,0 +1,66 @@
{
"name": "pfe004-evaluetonsavoir",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --host",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@fortawesome/fontawesome-free": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@mui/icons-material": "^5.14.18",
"@mui/lab": "^5.0.0-alpha.153",
"@mui/material": "^5.15.11",
"@types/uuid": "^9.0.7",
"axios": "^1.6.7",
"esbuild": "^0.20.2",
"gift-pegjs": "^0.2.1",
"jest-environment-jsdom": "^29.7.0",
"katex": "^0.16.9",
"marked": "^9.1.2",
"nanoid": "^5.0.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-latex": "^2.0.0",
"react-modal": "^3.16.1",
"react-router-dom": "^6.16.0",
"remark-math": "^6.0.0",
"socket.io-client": "^4.7.2",
"ts-node": "^10.9.1",
"uuid": "^9.0.1",
"vite-plugin-checker": "^0.6.2"
},
"devDependencies": {
"@babel/preset-env": "^7.23.3",
"@babel/preset-react": "^7.23.3",
"@babel/preset-typescript": "^7.23.3",
"@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.1.0",
"@types/jest": "^29.5.8",
"@types/node": "^20.8.8",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/react-latex": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"ts-jest": "^29.1.1",
"typescript": "^5.0.2",
"vite": "^4.4.5",
"vite-plugin-rewrite-all": "^1.0.1"
}
}

6
client/public/Logo.svg Normal file
View file

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="244" height="225" fill="none" viewBox="0 0 244 225">
<path stroke="#636363" stroke-width="4" d="M79.06 48.494 122 6.787l88.131 85.601L122 177.99l-42.94-41.707c-24.958-24.242-24.958-63.547 0-87.79Z"/>
<path stroke="#636363" stroke-width="4" d="M123 32.777c34.318 0 62 26.713 62 59.5 0 32.786-27.682 59.5-62 59.5-34.317 0-62-26.714-62-59.5 0-32.787 27.683-59.5 62-59.5Z"/>
<path stroke="#636363" stroke-width="4" d="M74 128.777v-73h96v73H74Zm53 1v-74m1 44h42m-26 30v-29m-15 12h16m-6 1v-13m2 6h4"/>
<path fill="#636363" d="M111.412 190.336c0 1.2-.576 2.268-1.728 3.204-1.128.936-2.436 1.62-3.924 2.052-1.464.432-2.88.648-4.248.648-.36 0-.624-.012-.792-.036v-1.224c.048 0 .744-.108 2.088-.324 1.344-.24 2.472-.648 3.384-1.224.936-.576 1.404-1.224 1.404-1.944 0-.864-.648-1.296-1.944-1.296-1.752 0-3.408.6-4.968 1.8-1.56 1.2-2.34 2.46-2.34 3.78 0 1.32.816 2.172 2.448 2.556.36.096.6.192.72.288.12.096.18.264.18.504s-.12.444-.36.612c-.216.144-.732.36-1.548.648-4.416 1.512-6.624 3.864-6.624 7.056 0 .648.12 1.2.36 1.656 1.464-1.872 3.36-3.396 5.688-4.572 2.328-1.176 4.572-1.764 6.732-1.764 1.128 0 2.028.252 2.7.756.672.48 1.008 1.152 1.008 2.016 0 1.344-.624 2.688-1.872 4.032-1.248 1.344-2.868 2.436-4.86 3.276-1.968.864-3.936 1.296-5.904 1.296-1.944 0-3.48-.36-4.608-1.08-.024.024-.132.204-.324.54-.48.96-.888 1.632-1.224 2.016l-1.188-.504c.168-.672.66-1.692 1.476-3.06-1.104-1.248-1.656-2.64-1.656-4.176 0-1.56.624-3.036 1.872-4.428 1.272-1.416 3.036-2.58 5.292-3.492-1.128-.936-1.692-1.908-1.692-2.916 0-1.368.72-2.76 2.16-4.176 1.44-1.44 3.192-2.604 5.256-3.492 2.088-.888 4.044-1.332 5.868-1.332.96 0 1.728.216 2.304.648.576.408.864.96.864 1.656Zm-7.56 14.58c-1.416 0-3.072.54-4.968 1.62-1.872 1.08-3.396 2.364-4.572 3.852.96 1.08 2.316 1.62 4.068 1.62 1.776 0 3.504-.564 5.184-1.692 1.704-1.152 2.556-2.388 2.556-3.708 0-.528-.204-.936-.612-1.224-.408-.312-.96-.468-1.656-.468Zm24.802-13.896-5.148-.108c-5.256 0-7.884 1.152-7.884 3.456 0 .6.216 1.092.648 1.476.456.36 1.128.54 2.016.54.888 0 2.052-.408 3.492-1.224l.54.936a8.62 8.62 0 0 1-2.52 1.872c-.936.456-1.872.684-2.808.684-.936 0-1.74-.312-2.412-.936-.672-.624-1.008-1.404-1.008-2.34 0-1.896 1.02-3.552 3.06-4.968 2.064-1.416 4.848-2.124 8.352-2.124.288 0 1.32.048 3.096.144 1.776.096 3.18.144 4.212.144 1.032 0 1.98-.156 2.844-.468.336-.096.54-.144.612-.144.336 0 .504.216.504.648 0 .624-.552 1.188-1.656 1.692-1.08.504-2.328.756-3.744.756-1.584 1.536-2.976 4.224-4.176 8.064-1.176 3.816-2.592 6.732-4.248 8.748-1.632 1.992-3.576 2.988-5.832 2.988-.888 0-1.728-.288-2.52-.864l.828-1.368c.288.144.612.276.972.396l.504.072c.12.024.3.036.54.036 1.2 0 2.268-.576 3.204-1.728.96-1.152 1.956-3.324 2.988-6.516 1.056-3.216 1.872-5.34 2.448-6.372.6-1.056 1.632-2.22 3.096-3.492Zm23.303 13.464 2.556-.036v1.476a6.202 6.202 0 0 0-1.008-.072c-.384 0-.816.024-1.296.072 0 1.848-.888 3.72-2.664 5.616-1.776 1.896-3.924 3.432-6.444 4.608-2.496 1.2-4.824 1.8-6.984 1.8-1.128 0-2.052-.288-2.772-.864-.696-.576-1.044-1.332-1.044-2.268 0-1.392.924-2.844 2.772-4.356 1.848-1.512 4.008-2.76 6.48-3.744 2.472-.984 4.704-1.56 6.696-1.728-.24-.36-.612-.672-1.116-.936-.504-.264-1.536-.672-3.096-1.224-1.56-.576-2.664-1.2-3.312-1.872-.648-.696-.972-1.668-.972-2.916s.672-2.676 2.016-4.284a18.287 18.287 0 0 1 4.752-4.032c1.848-1.104 3.468-1.656 4.86-1.656.624 0 1.14.228 1.548.684.408.432.612.972.612 1.62 0 1.056-.636 2.388-1.908 3.996-1.272 1.584-2.688 2.796-4.248 3.636l-.864-1.224c.936-.48 1.752-1.224 2.448-2.232.72-1.032 1.08-1.92 1.08-2.664 0-.744-.396-1.116-1.188-1.116-1.128 0-2.424.708-3.888 2.124-1.44 1.392-2.16 2.7-2.16 3.924 0 .84.216 1.5.648 1.98.432.48 1.524 1.08 3.276 1.8 1.752.696 3 1.32 3.744 1.872.744.528 1.236 1.2 1.476 2.016Zm-16.056 9.396c0 1.152.936 1.728 2.808 1.728 1.032 0 2.364-.384 3.996-1.152a13.126 13.126 0 0 0 4.356-3.168c1.272-1.344 1.908-2.724 1.908-4.14 0-.168-.036-.42-.108-.756-3.288.312-6.276 1.236-8.964 2.772-2.664 1.536-3.996 3.108-3.996 4.716Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

BIN
client/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

4
client/public/people.svg Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg fill="#000000" width="800px" height="800px" viewBox="0 0 32 32" version="1.1" xmlns="http://www.w3.org/2000/svg">
<path d="M23.313 26.102l-6.296-3.488c2.34-1.841 2.976-5.459 2.976-7.488v-4.223c0-2.796-3.715-5.91-7.447-5.91-3.73 0-7.544 3.114-7.544 5.91v4.223c0 1.845 0.78 5.576 3.144 7.472l-6.458 3.503s-1.688 0.752-1.688 1.689v2.534c0 0.933 0.757 1.689 1.688 1.689h21.625c0.931 0 1.688-0.757 1.688-1.689v-2.534c0-0.994-1.689-1.689-1.689-1.689zM23.001 30.015h-21.001v-1.788c0.143-0.105 0.344-0.226 0.502-0.298 0.047-0.021 0.094-0.044 0.139-0.070l6.459-3.503c0.589-0.32 0.979-0.912 1.039-1.579s-0.219-1.32-0.741-1.739c-1.677-1.345-2.396-4.322-2.396-5.911v-4.223c0-1.437 2.708-3.91 5.544-3.91 2.889 0 5.447 2.44 5.447 3.91v4.223c0 1.566-0.486 4.557-2.212 5.915-0.528 0.416-0.813 1.070-0.757 1.739s0.446 1.267 1.035 1.589l6.296 3.488c0.055 0.030 0.126 0.063 0.184 0.089 0.148 0.063 0.329 0.167 0.462 0.259v1.809zM30.312 21.123l-6.39-3.488c2.34-1.841 3.070-5.459 3.070-7.488v-4.223c0-2.796-3.808-5.941-7.54-5.941-2.425 0-4.904 1.319-6.347 3.007 0.823 0.051 1.73 0.052 2.514 0.302 1.054-0.821 2.386-1.308 3.833-1.308 2.889 0 5.54 2.47 5.54 3.941v4.223c0 1.566-0.58 4.557-2.305 5.915-0.529 0.416-0.813 1.070-0.757 1.739 0.056 0.67 0.445 1.267 1.035 1.589l6.39 3.488c0.055 0.030 0.126 0.063 0.184 0.089 0.148 0.063 0.329 0.167 0.462 0.259v1.779h-4.037c0.61 0.46 0.794 1.118 1.031 2h3.319c0.931 0 1.688-0.757 1.688-1.689v-2.503c-0.001-0.995-1.689-1.691-1.689-1.691z"></path>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

11
client/public/student.svg Normal file
View file

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.3 8.40007L21.82 6.40008C21.7248 6.27314 21.6008 6.17066 21.4583 6.10111C21.3157 6.03156 21.1586 5.99693 21 6.00008H11.2C11.0555 6.00007 10.9128 6.03135 10.7816 6.09177C10.6504 6.15219 10.5339 6.24031 10.44 6.35007L8.71998 8.35008C8.57227 8.53401 8.49435 8.76424 8.49998 9.00008V16.2901C8.50262 18.0317 9.19567 19.7013 10.4272 20.9328C11.6588 22.1644 13.3283 22.8574 15.07 22.8601H16.93C18.6716 22.8574 20.3412 22.1644 21.5728 20.9328C22.8043 19.7013 23.4973 18.0317 23.5 16.2901V9.00008C23.5 8.7837 23.4298 8.57317 23.3 8.40007Z" fill="#FFCC80"/>
<path d="M29.78 28.38L25.78 23.38C25.664 23.2321 25.5086 23.1198 25.3318 23.0562C25.1549 22.9925 24.9637 22.98 24.78 23.02L16 25L7.21999 23C7.03632 22.96 6.84507 22.9725 6.6682 23.0362C6.49133 23.0998 6.33598 23.2121 6.21999 23.36L2.21999 28.36C2.10392 28.5064 2.03116 28.6823 2.00995 28.8679C1.98874 29.0534 2.01993 29.2413 2.09999 29.41C2.17815 29.5839 2.3044 29.7319 2.46385 29.8364C2.62331 29.9409 2.80933 29.9977 2.99999 30H29C29.1885 29.9995 29.373 29.9457 29.5322 29.8448C29.6914 29.744 29.8189 29.6002 29.9 29.43C29.98 29.2613 30.0112 29.0734 29.99 28.8879C29.9688 28.7023 29.8961 28.5264 29.78 28.38Z" fill="#01579B"/>
<path d="M29.29 6.00003L16.29 2.00003C16.0999 1.95002 15.9001 1.95002 15.71 2.00003L2.71 6.00003C2.49742 6.06422 2.31226 6.19735 2.1837 6.37841C2.05515 6.55947 1.99052 6.77817 2 7.00003C1.9917 7.22447 2.0592 7.44518 2.19163 7.62659C2.32405 7.80799 2.5137 7.93954 2.73 8.00003L15.73 11.6C15.906 11.6534 16.094 11.6534 16.27 11.6L29.27 8.00003C29.4863 7.93954 29.6759 7.80799 29.8084 7.62659C29.9408 7.44518 30.0083 7.22447 30 7.00003C30.0095 6.77817 29.9448 6.55947 29.8163 6.37841C29.6877 6.19735 29.5026 6.06422 29.29 6.00003Z" fill="#01579B"/>
<path d="M11.22 6C11.0756 5.99999 10.9328 6.03127 10.8016 6.09169C10.6704 6.15211 10.5539 6.24023 10.46 6.35L8.74 8.35C8.58509 8.53114 8.49998 8.76166 8.5 9V16.29C8.50264 18.0317 9.19569 19.7012 10.4272 20.9328C11.6588 22.1643 13.3283 22.8574 15.07 22.86H16V6H11.22Z" fill="#FFE0B2"/>
<path d="M7.21999 23C7.03632 22.96 6.84507 22.9725 6.6682 23.0362C6.49133 23.0998 6.33598 23.2121 6.21999 23.36L2.21999 28.36C2.10392 28.5064 2.03116 28.6823 2.00995 28.8679C1.98874 29.0534 2.01993 29.2413 2.09999 29.41C2.17815 29.5839 2.3044 29.7319 2.46385 29.8364C2.62331 29.9409 2.80933 29.9977 2.99999 30H16V25L7.21999 23Z" fill="#0277BD"/>
<path d="M15.71 2.00002L2.71 6.00002C2.49742 6.06422 2.31226 6.19734 2.1837 6.3784C2.05515 6.55947 1.99052 6.77817 2 7.00002C1.9917 7.22447 2.0592 7.44518 2.19163 7.62658C2.32405 7.80799 2.5137 7.93954 2.73 8.00002L15.73 11.6C15.8194 11.6146 15.9106 11.6146 16 11.6V2.00002C15.9039 1.98469 15.8061 1.98469 15.71 2.00002Z" fill="#0277BD"/>
<path d="M2.73 8.00003L8.5 9.56003V16.29C8.50264 18.0317 9.19569 19.7013 10.4272 20.9328C11.6588 22.1643 13.3283 22.8574 15.07 22.86H16.93C18.6717 22.8574 20.3412 22.1643 21.5728 20.9328C22.8043 19.7013 23.4974 18.0317 23.5 16.29V9.56003L29.27 8.00003C29.4863 7.93954 29.6759 7.80799 29.8084 7.62659C29.9408 7.44518 30.0083 7.22447 30 7.00003C30.0095 6.77817 29.9448 6.55947 29.8163 6.37841C29.6877 6.19735 29.5026 6.06422 29.29 6.00003L16.29 2.00003C16.0999 1.95002 15.9001 1.95002 15.71 2.00003L2.71 6.00003C2.49742 6.06422 2.31226 6.19735 2.1837 6.37841C2.05515 6.55947 1.99052 6.77817 2 7.00003C1.9917 7.22447 2.0592 7.44518 2.19163 7.62659C2.32405 7.80799 2.5137 7.93954 2.73 8.00003ZM21.5 16.29C21.4974 17.5013 21.015 18.6621 20.1586 19.5186C19.3021 20.3751 18.1412 20.8574 16.93 20.86H15.07C13.8588 20.8574 12.6979 20.3751 11.8414 19.5186C10.985 18.6621 10.5026 17.5013 10.5 16.29V10.11L15.73 11.56C15.906 11.6134 16.094 11.6134 16.27 11.56L21.5 10.11V16.29ZM16 4.05003L25.44 7.00003L16 9.56003L6.56 7.00003L16 4.05003Z" fill="#263238"/>
<path d="M25.78 23.38C25.664 23.2321 25.5086 23.1198 25.3318 23.0562C25.1549 22.9925 24.9637 22.98 24.78 23.02L16 25L7.21999 23C7.03632 22.96 6.84507 22.9725 6.6682 23.0362C6.49133 23.0998 6.33598 23.2121 6.21999 23.36L2.21999 28.36C2.10392 28.5064 2.03116 28.6823 2.00995 28.8679C1.98874 29.0534 2.01993 29.2413 2.09999 29.41C2.17815 29.5839 2.3044 29.7319 2.46385 29.8364C2.62331 29.9409 2.80933 29.9977 2.99999 30H29C29.1885 29.9995 29.373 29.9457 29.5322 29.8448C29.6914 29.744 29.8189 29.6002 29.9 29.43C29.98 29.2613 30.0112 29.0734 29.99 28.8879C29.9688 28.7023 29.8961 28.5264 29.78 28.38L25.78 23.38ZM5.07999 28L7.38999 25.11L15.78 27C15.9251 27.0299 16.0748 27.0299 16.22 27L24.61 25.13L26.92 28H5.07999Z" fill="#263238"/>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

238
client/public/teacher.svg Normal file
View file

@ -0,0 +1,238 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--noto" preserveAspectRatio="xMidYMid meet">
<linearGradient id="IconifyId17ecdb2904d178eab20895" gradientUnits="userSpaceOnUse" x1="63.999" y1="116.605" x2="63.999" y2="39.511" gradientTransform="matrix(1 0 0 -1 0 128)">
<stop offset="0" stop-color="#26a69a">
</stop>
<stop offset="1" stop-color="#00796b">
</stop>
</linearGradient>
<path fill="url(#IconifyId17ecdb2904d178eab20895)" d="M6.36 10.9h115.29v77.52H6.36z">
</path>
<linearGradient id="IconifyId17ecdb2904d178eab20896" gradientUnits="userSpaceOnUse" x1="63.999" y1="119.455" x2="63.999" y2="37.224" gradientTransform="matrix(1 0 0 -1 0 128)">
<stop offset="0" stop-color="#8d6e63">
</stop>
<stop offset=".779" stop-color="#795548">
</stop>
</linearGradient>
<path d="M119.29 13.26v72.81H8.71V13.26h110.58M124 8.55H4v82.23h120V8.55z" fill="url(#IconifyId17ecdb2904d178eab20896)">
</path>
<path d="M98.9 79.85c-1.25-2.27.34-4.58 3.06-7.44c4.31-4.54 9-15.07 4.64-25.76c.03-.06-.86-1.86-.83-1.92l-1.79-.09c-.57-.08-20.26-.12-39.97-.12s-39.4.04-39.97.12c0 0-2.65 1.95-2.63 2.01c-4.35 10.69.33 21.21 4.64 25.76c2.71 2.86 4.3 5.17 3.06 7.44c-1.21 2.21-4.81 2.53-4.81 2.53s.83 2.26 2.83 3.48c1.85 1.13 4.13 1.39 5.7 1.43c0 0 6.15 8.51 22.23 8.51h17.9c16.08 0 22.23-8.51 22.23-8.51c1.57-.04 3.85-.3 5.7-1.43c2-1.22 2.83-3.48 2.83-3.48s-3.61-.32-4.82-2.53z" fill="#232020">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20897" cx="99.638" cy="45.85" r="23.419" gradientTransform="matrix(1 0 0 .4912 -21.055 59.629)" gradientUnits="userSpaceOnUse">
<stop offset=".728" stop-color="#444140" stop-opacity="0">
</stop>
<stop offset="1" stop-color="#444140">
</stop>
</radialGradient>
<path d="M63.99 95.79v-9.44l28.57-2.26l2.6 3.2s-6.15 8.51-22.23 8.51l-8.94-.01z" fill="url(#IconifyId17ecdb2904d178eab20897)">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20898" cx="76.573" cy="49.332" r="6.921" gradientTransform="matrix(-.9057 .4238 -.3144 -.6719 186.513 79.36)" gradientUnits="userSpaceOnUse">
<stop offset=".663" stop-color="#444140">
</stop>
<stop offset="1" stop-color="#444140" stop-opacity="0">
</stop>
</radialGradient>
<path d="M95.1 83.16c-4.28-6.5 5.21-8.93 5.21-8.93l.01.01c-1.65 2.05-2.4 3.84-1.43 5.61c1.21 2.21 4.81 2.53 4.81 2.53s-4.91 4.36-8.6.78z" fill="url(#IconifyId17ecdb2904d178eab20898)">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20899" cx="94.509" cy="68.91" r="30.399" gradientTransform="matrix(-.0746 -.9972 .8311 -.0622 33.494 157.622)" gradientUnits="userSpaceOnUse">
<stop offset=".725" stop-color="#444140" stop-opacity="0">
</stop>
<stop offset="1" stop-color="#444140">
</stop>
</radialGradient>
<path d="M106.62 46.65c4.25 10.35-.22 21.01-4.41 25.51c-.57.62-3.01 3.01-3.57 4.92c0 0-9.54-13.31-12.39-21.13c-.57-1.58-1.1-3.2-1.17-4.88c-.05-1.26.14-2.76.87-3.83c.89-1.31 20.16-1.7 20.16-1.7c0 .01.51 1.11.51 1.11z" fill="url(#IconifyId17ecdb2904d178eab20899)">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20900" cx="44.31" cy="68.91" r="30.399" gradientTransform="matrix(.0746 -.9972 -.8311 -.0622 98.274 107.563)" gradientUnits="userSpaceOnUse">
<stop offset=".725" stop-color="#444140" stop-opacity="0">
</stop>
<stop offset="1" stop-color="#444140">
</stop>
</radialGradient>
<path d="M21.4 46.65c-4.24 10.35.23 21.01 4.41 25.5c.58.62 3.01 3.01 3.57 4.92c0 0 9.54-13.31 12.39-21.13c.58-1.58 1.1-3.2 1.17-4.88c.05-1.26-.14-2.76-.87-3.83c-.89-1.31-1.93-.96-3.44-.96c-2.88 0-15.49-.74-16.47-.74c.01.02-.76 1.12-.76 1.12z" fill="url(#IconifyId17ecdb2904d178eab20900)">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20901" cx="49.439" cy="45.85" r="23.419" gradientTransform="matrix(-1 0 0 .4912 98.878 59.629)" gradientUnits="userSpaceOnUse">
<stop offset=".728" stop-color="#444140" stop-opacity="0">
</stop>
<stop offset="1" stop-color="#444140">
</stop>
</radialGradient>
<path d="M64.03 95.79v-9.44l-28.57-2.26l-2.6 3.2s6.15 8.51 22.23 8.51l8.94-.01z" fill="url(#IconifyId17ecdb2904d178eab20901)">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20902" cx="26.374" cy="49.332" r="6.921" gradientTransform="matrix(.9057 .4238 .3144 -.6719 -13.024 100.635)" gradientUnits="userSpaceOnUse">
<stop offset=".663" stop-color="#444140">
</stop>
<stop offset="1" stop-color="#444140" stop-opacity="0">
</stop>
</radialGradient>
<path d="M32.92 83.16c4.28-6.5-5.21-8.93-5.21-8.93l-.01.01c1.65 2.05 2.4 3.84 1.43 5.61c-1.21 2.21-4.81 2.53-4.81 2.53s4.91 4.36 8.6.78z" fill="url(#IconifyId17ecdb2904d178eab20902)">
</path>
<linearGradient id="IconifyId17ecdb2904d178eab20903" gradientUnits="userSpaceOnUse" x1="64" y1="25.908" x2="64" y2="10.938" gradientTransform="matrix(1 0 0 -1 0 128)">
<stop offset="0" stop-color="#e1f5fe">
</stop>
<stop offset="1" stop-color="#81d4fa">
</stop>
</linearGradient>
<path d="M114.5 120.75c0-15.47-25.34-23.56-50.36-23.56H64c-25.14.03-50.5 7.32-50.5 23.56V124h101v-3.25z" fill="url(#IconifyId17ecdb2904d178eab20903)">
</path>
<g>
<path fill="#3c2b24" d="M64 92.33h-9.08v9.98l9.06 2.38l9.1-2.38v-9.98z">
</path>
</g>
<g>
<linearGradient id="IconifyId17ecdb2904d178eab20904" gradientUnits="userSpaceOnUse" x1="29.113" y1="29.156" x2="29.113" y2="4.97" gradientTransform="matrix(1 0 0 -1 0 128)">
<stop offset="0" stop-color="#ffa000">
</stop>
<stop offset=".341" stop-color="#ff9300">
</stop>
<stop offset=".972" stop-color="#ff7100">
</stop>
<stop offset="1" stop-color="#ff6f00">
</stop>
</linearGradient>
<path d="M12 120.75V124h32.89l1.33-27.04C27.52 99.72 12 107.15 12 120.75z" fill="url(#IconifyId17ecdb2904d178eab20904)">
</path>
<linearGradient id="IconifyId17ecdb2904d178eab20905" gradientUnits="userSpaceOnUse" x1="98.888" y1="29.435" x2="98.888" y2="4.807" gradientTransform="matrix(1 0 0 -1 0 128)">
<stop offset="0" stop-color="#ffa000">
</stop>
<stop offset=".341" stop-color="#ff9300">
</stop>
<stop offset=".972" stop-color="#ff7100">
</stop>
<stop offset="1" stop-color="#ff6f00">
</stop>
</linearGradient>
<path d="M81.78 96.96L83.1 124H116v-3.25c0-13.6-15.52-21.03-34.22-23.79z" fill="url(#IconifyId17ecdb2904d178eab20905)">
</path>
<g>
<path fill="#66c0e8" d="M54.03 92.12l9.99 12.82l-16.24 6.64l-2.41-14.54z">
</path>
<path fill="#66c0e8" d="M73.97 92.12l-9.99 12.82l16.24 6.64l2.41-14.54z">
</path>
</g>
<g>
<path d="M48.88 95s-1.14 2.72-1.94 6c-1.59 6.52-1.69 15.8-1.69 15.8s-6.89-2.34-9.04-8.05c-2.54-6.75 1.75-10.46 1.75-10.46s.9-.38 4.68-2.46s6.24-.83 6.24-.83z" fill="#af5214">
</path>
</g>
<g>
<path d="M79.12 95s1.14 2.72 1.94 6c1.59 6.52 1.69 15.8 1.69 15.8s6.89-2.34 9.04-8.05c2.54-6.75-1.75-10.46-1.75-10.46s-.9-.38-4.68-2.46s-6.24-.83-6.24-.83z" fill="#af5214">
</path>
</g>
</g>
<g>
<path d="M91.12 50.43H36.47c-5.89 0-10.71 5.14-10.71 11.41s4.82 11.41 10.71 11.41H91.12c5.89 0 10.71-5.14 10.71-11.41s-4.82-11.41-10.71-11.41z" fill="#3c2b24">
</path>
</g>
<g>
<path d="M63.79 11.07c-17.4 0-33.52 18.61-33.52 45.4c0 26.64 16.61 39.81 33.52 39.81S97.31 83.1 97.31 56.46c0-26.78-16.11-45.39-33.52-45.39z" fill="#70534a">
</path>
</g>
<g fill="#1a1717">
<ellipse cx="47.98" cy="58.81" rx="4.93" ry="5.1">
</ellipse>
<ellipse cx="79.13" cy="58.81" rx="4.93" ry="5.1">
</ellipse>
</g>
<g fill="#1a1717">
<path d="M55.37 49.82c-.93-1.23-3.07-3.01-7.23-3.01s-6.31 1.79-7.23 3.01c-.41.54-.31 1.17-.02 1.55c.26.35 1.04.68 1.9.39s2.54-1.16 5.35-1.18c2.81.02 4.49.89 5.35 1.18c.86.29 1.64-.03 1.9-.39c.28-.38.39-1.01-.02-1.55z">
</path>
<path d="M86.36 49.82c-.93-1.23-3.07-3.01-7.23-3.01s-6.31 1.79-7.23 3.01c-.41.54-.31 1.17-.02 1.55c.26.35 1.04.68 1.9.39s2.54-1.16 5.35-1.18c2.81.02 4.49.89 5.35 1.18c.86.29 1.64-.03 1.9-.39c.29-.38.39-1.01-.02-1.55z">
</path>
</g>
<path d="M67.65 68.06c-.11-.04-.21-.07-.32-.08h-7.08c-.11.01-.22.04-.32.08c-.64.26-.99.92-.69 1.63c.3.71 1.71 2.69 4.55 2.69s4.25-1.99 4.55-2.69c.31-.71-.05-1.37-.69-1.63z" fill="#33251f">
</path>
<path d="M72.32 76.14c-3.18 1.89-13.63 1.89-16.81 0c-1.83-1.09-3.7.58-2.94 2.24c.75 1.63 6.44 5.42 11.37 5.42s10.55-3.79 11.3-5.42c.76-1.66-1.09-3.33-2.92-2.24z" fill="#1a1717">
</path>
<path d="M93.83 52.93c-.07-1.19-.12-1.31-1.69-1.81c-1.23-.39-7.95-.94-13.01-.66c-.36.02-.71.04-1.04.07c-4.59.39-10.1 2.24-14.24 2.34c-1.76.04-9.01-1.86-14.14-2.26c-.33-.02-.66-.05-1-.06c-5.07-.26-11.82.33-13.05.73c-1.57.51-1.62.63-1.68 1.82c-.07 1.19.13 2.2 1.06 2.51c1.27.42 1.28 2 2.13 6.54c.77 4.14 2.62 7.41 10.57 7.98c.34.02.66.04.98.04c7.03.1 9.45-4.53 10.25-6.07c1.49-2.86 1.02-6.8 4.96-6.81c3.93-.01 3.56 3.86 5.07 6.71c.81 1.53 3.17 6.18 10.14 6.08c.34 0 .69-.02 1.05-.05c7.94-.62 9.78-3.9 10.52-8.04c.82-4.55.83-6.14 2.09-6.56c.91-.3 1.11-1.31 1.03-2.5zM53.28 68.17c-1.22.57-2.85.86-4.57.86c-3.59-.01-7.57-1.27-9.01-3.81c-2.04-3.62-2.57-10.94.03-12.47c1.14-.67 4.99-1.13 8.97-.96c4.13.18 8.4 1.04 9.94 3.06c2.55 3.33-1.5 11.5-5.36 13.32zm34.9-3.1c-1.43 2.56-5.44 3.85-9.05 3.86c-1.7.01-3.31-.27-4.51-.83c-3.87-1.8-7.97-9.94-5.45-13.29c1.53-2.04 5.82-2.92 9.96-3.12c3.97-.19 7.81.25 8.94.91c2.61 1.52 2.13 8.84.11 12.47z" fill="#212121" stroke="#212121" stroke-width=".55" stroke-miterlimit="10">
</path>
<g>
<linearGradient id="IconifyId17ecdb2904d178eab20906" gradientUnits="userSpaceOnUse" x1="79.569" y1="22.713" x2="76.946" y2="11.668" gradientTransform="matrix(1 0 0 -1 0 128)">
<stop offset=".002" stop-color="#212121" stop-opacity=".2">
</stop>
<stop offset="1" stop-color="#212121" stop-opacity=".6">
</stop>
</linearGradient>
<path d="M101.67 121.61l.57-2.2l.01-.05l1.93-7.6l-6.9-1.98l-34.92-10.03c-.05-.01-.09-.01-.13-.03a6.177 6.177 0 0 0-7.51 4.27L48.97 124h52.02l.68-2.39z" opacity=".67" fill="url(#IconifyId17ecdb2904d178eab20906)">
</path>
<path d="M105.75 111.88c.29-1.01-.29-2.06-1.3-2.34l-38.69-11.1a6.19 6.19 0 0 0-7.65 4.24L52 124h50.28l3.47-12.12z" fill="#424242">
</path>
<linearGradient id="IconifyId17ecdb2904d178eab20907" gradientUnits="userSpaceOnUse" x1="81.84" y1="17.098" x2="79.869" y2="10.486" gradientTransform="matrix(1 0 0 -1 0 128)">
<stop offset="0" stop-color="#ef5350">
</stop>
<stop offset="1" stop-color="#e53935">
</stop>
</linearGradient>
<path d="M105.08 120.31c.35-1.22-.38-2.5-1.62-2.85l-41.52-11.9c-4.53-1.3-5.32 2.35-6.59 6.78L52 124h52.02l1.06-3.69z" fill="url(#IconifyId17ecdb2904d178eab20907)">
</path>
<linearGradient id="IconifyId17ecdb2904d178eab20908" gradientUnits="userSpaceOnUse" x1="58.405" y1="19.113" x2="60.268" y2="24.969" gradientTransform="matrix(1 0 0 -1 0 128)">
<stop offset="0" stop-color="#212121">
</stop>
<stop offset="1" stop-color="#424242">
</stop>
</linearGradient>
<path d="M63.26 98.24a6.172 6.172 0 0 0-5.14 4.42L52 124h3.87l7.39-25.76z" fill="url(#IconifyId17ecdb2904d178eab20908)">
</path>
<path d="M64.33 101.57c.18 0 .38.02.59.07l37.25 10.7l-.31 1.08c-11.79-3.29-34.29-9.62-38.94-11.16c.24-.33.71-.69 1.41-.69m0-3.33c-4.52 0-6.78 5.57-3.12 6.94c4.03 1.5 42.93 12.32 42.93 12.32l1.58-5.52c.31-1.06-.19-2.14-1.11-2.4L65.77 98.42c-.5-.12-.98-.18-1.44-.18z" fill="#424242" opacity=".2">
</path>
<linearGradient id="IconifyId17ecdb2904d178eab20909" gradientUnits="userSpaceOnUse" x1="-117.44" y1="-972.312" x2="-73.995" y2="-972.312" gradientTransform="matrix(.9612 .2758 -.3192 1.1123 -136.555 1216.41)">
<stop offset=".01" stop-color="#bdbdbd">
</stop>
<stop offset=".987" stop-color="#f8f8f7">
</stop>
</linearGradient>
<path d="M103.37 112.12l-39.8-11.42c-1.08-.31-2.26.46-2.62 1.71l-.06.22c-.36 1.25.23 2.53 1.31 2.84l39.8 11.42s-.34-.83.07-2.3c.41-1.48 1.3-2.47 1.3-2.47z" fill="url(#IconifyId17ecdb2904d178eab20909)">
</path>
</g>
<g>
<path d="M104.07 25.11c-2.44-3.69-7.91-8.64-12.82-8.97c-.79-4.72-5.84-8.72-10.73-10.27c-13.23-4.19-21.84.51-26.46 3.03c-.96.52-7.17 3.97-11.51 1.5c-2.72-1.55-2.67-5.74-2.67-5.74s-8.52 3.25-5.61 12.3c-2.93.12-6.77 1.36-8.8 5.47c-2.42 4.9-1.56 8.99-.86 10.95c-2.52 2.14-5.69 6.69-3.52 12.6c1.64 4.45 8.17 6.5 8.17 6.5c-.46 8.01 1.03 12.94 1.82 14.93c.14.35.63.32.72-.04c.99-3.97 4.37-17.8 4.03-20.21c0 0 11.35-2.25 22.17-10.22c2.2-1.62 4.59-3 7.13-4.01c13.59-5.41 16.43 3.82 16.43 3.82s9.42-1.81 12.26 11.27c1.07 4.9 1.79 12.75 2.4 18.24c.04.39.57.47.72.11c.95-2.18 2.85-6.5 3.3-10.91c.16-1.55 4.34-3.6 6.14-10.26c2.41-8.88-.54-17.42-2.31-20.09z" fill="#232020">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20910" cx="82.019" cy="84.946" r="35.633" gradientTransform="matrix(.3076 .9515 .706 -.2282 -3.184 -15.605)" gradientUnits="userSpaceOnUse">
<stop offset=".699" stop-color="#444140" stop-opacity="0">
</stop>
<stop offset="1" stop-color="#444140">
</stop>
</radialGradient>
<path d="M100.22 55.5c.16-1.55 4.34-3.6 6.14-10.26c.19-.71.35-1.43.5-2.15c1.46-8.09-1.16-15.52-2.79-17.98c-2.26-3.41-7.1-7.89-11.69-8.81c-.4-.05-.79-.1-1.16-.12c0 0 .33 2.15-.54 3.86c-1.12 2.22-3.41 2.75-3.41 2.75c11.97 11.98 11.12 22 12.95 32.71z" fill="url(#IconifyId17ecdb2904d178eab20910)">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20911" cx="47.28" cy="123.8" r="9.343" gradientTransform="matrix(.8813 .4726 .5603 -1.045 -63.752 111.228)" gradientUnits="userSpaceOnUse">
<stop offset=".58" stop-color="#444140">
</stop>
<stop offset="1" stop-color="#444140" stop-opacity="0">
</stop>
</radialGradient>
<path d="M56.95 7.39c-1.1.53-2.06 1.06-2.9 1.51c-.96.52-7.17 3.97-11.51 1.5c-2.67-1.52-2.67-5.58-2.67-5.72c-1.23 1.57-4.95 12.78 5.93 13.53c4.69.32 7.58-3.77 9.3-7.23c.62-1.26 1.59-3.1 1.85-3.59z" fill="url(#IconifyId17ecdb2904d178eab20911)">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20912" cx="159.055" cy="62.862" r="28.721" gradientTransform="matrix(-.9378 -.3944 -.2182 .5285 231.04 50.678)" gradientUnits="userSpaceOnUse">
<stop offset=".699" stop-color="#444140" stop-opacity="0">
</stop>
<stop offset="1" stop-color="#444140">
</stop>
</radialGradient>
<path d="M79.16 5.47c7.32 1.98 10.89 5.71 12.08 10.68c.35 1.46.77 15.08-25.23-.4c-9.67-5.76-7.03-9.36-5.9-9.77c4.42-1.6 10.85-2.73 19.05-.51z" fill="url(#IconifyId17ecdb2904d178eab20912)">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20913" cx="43.529" cy="115.276" r="8.575" gradientTransform="matrix(1 0 0 -1.2233 0 153.742)" gradientUnits="userSpaceOnUse">
<stop offset=".702" stop-color="#444140" stop-opacity="0">
</stop>
<stop offset="1" stop-color="#444140">
</stop>
</radialGradient>
<path d="M39.84 4.68c-.01.01-.03.01-.06.03h-.01c-.93.39-8.24 3.78-5.51 12.25l7.78 1.25c-6.89-6.98-2.17-13.55-2.17-13.55s-.02.01-.03.02z" fill="url(#IconifyId17ecdb2904d178eab20913)">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20914" cx="42.349" cy="100.139" r="16.083" gradientTransform="matrix(-.9657 -.2598 -.2432 .9037 107.598 -51.632)" gradientUnits="userSpaceOnUse">
<stop offset=".66" stop-color="#444140" stop-opacity="0">
</stop>
<stop offset="1" stop-color="#444140">
</stop>
</radialGradient>
<path d="M39.07 17.73l-4.81-.77c-.19 0-.83.06-1.18.11c-2.71.38-5.9 1.78-7.63 5.36c-1.86 3.86-1.81 7.17-1.3 9.38c.15.74.45 1.58.45 1.58s2.38-2.26 8.05-2.41l6.42-13.25z" fill="url(#IconifyId17ecdb2904d178eab20914)">
</path>
<radialGradient id="IconifyId17ecdb2904d178eab20915" cx="38.533" cy="84.609" r="16.886" gradientTransform="matrix(.9907 .1363 .1915 -1.3921 -15.841 155.923)" gradientUnits="userSpaceOnUse">
<stop offset=".598" stop-color="#444140" stop-opacity="0">
</stop>
<stop offset="1" stop-color="#444140">
</stop>
</radialGradient>
<path d="M24.37 33.58c-2.37 2.1-5.56 6.79-3.21 12.61c1.77 4.39 8.09 6.29 8.09 6.29c0 .02 1.26.4 1.91.4l1.48-21.9c-3.03 0-5.94.91-7.82 2.22c.03.03-.46.35-.45.38z" fill="url(#IconifyId17ecdb2904d178eab20915)">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 16 KiB

1
client/public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -0,0 +1,13 @@
## PULL REQUEST
## Description
## Link to the ticket
## Checklist before requesting a review
- [ ] I have performed a self-review of my code
- [ ] If it is a core feature, I have added thorough tests.
- [ ] Do we need to implement analytics?
- [ ] Will this be part of a product update? If yes, please write one phrase about this update.

66
client/src/App.tsx Normal file
View file

@ -0,0 +1,66 @@
// App.tsx
import { Routes, Route } from 'react-router-dom';
// Page main
import Home from './pages/Home/Home';
// Pages espace enseignant
import Dashboard from './pages/Teacher/Dashboard/Dashboard';
import Share from './pages/Teacher/Share/Share';
import Login from './pages/Teacher/Login/Login';
import Register from './pages/Teacher/Register/Register';
import ResetPassword from './pages/Teacher/ResetPassword/ResetPassword';
import ManageRoom from './pages/Teacher/ManageRoom/ManageRoom';
import QuizForm from './pages/Teacher/EditorQuiz/EditorQuiz';
// Pages espace étudiant
import JoinRoom from './pages/Student/JoinRoom/JoinRoom';
// Header/Footer import
import Header from './components/Header/Header';
import Footer from './components/Footer/Footer';
import ApiService from './services/ApiService';
const handleLogout = () => {
ApiService.logout();
}
const isLoggedIn = () => {
return ApiService.isLogedIn();
}
function App() {
return (
<div className="content">
<Header
isLoggedIn={isLoggedIn}
handleLogout={handleLogout}/>
<div className="app">
<main>
<Routes>
{/* Page main */}
<Route path="/" element={<Home />} />
{/* Pages espace enseignant */}
<Route path="/teacher/login" element={<Login />} />
<Route path="/teacher/register" element={<Register />} />
<Route path="/teacher/resetPassword" element={<ResetPassword />} />
<Route path="/teacher/dashboard" element={<Dashboard />} />
<Route path="/teacher/share/:id" element={<Share />} />
<Route path="/teacher/editor-quiz/:id" element={<QuizForm />} />
<Route path="/teacher/manage-room/:id" element={<ManageRoom />} />
{/* Pages espace étudiant */}
<Route path="/student/join-room" element={<JoinRoom />} />
</Routes>
</main>
</div>
<Footer/>
</div>
);
}
export default App;

View file

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

View file

@ -0,0 +1,6 @@
import { GIFTQuestion } from 'gift-pegjs';
export interface QuestionType {
question: GIFTQuestion;
image: string;
}

View file

@ -0,0 +1,10 @@
// QuizType.tsx
export interface QuizType {
_id: string;
folderId: string;
userId: string;
title: string;
content: string[];
created_at: Date;
updated_at: Date;
}

View file

@ -0,0 +1,4 @@
export interface UserType {
name: string;
id: string;
}

View file

@ -0,0 +1,43 @@
//QuestionType.test.tsx
import { GIFTQuestion } from 'gift-pegjs';
import { QuestionType } from '../../Types/QuestionType';
const mockQuestion: GIFTQuestion = {
id: '1',
type: 'MC',
stem: { format: 'plain', text: 'Sample Question' },
title: 'Sample Question',
hasEmbeddedAnswers: false,
globalFeedback: null,
choices: [
{ text: { format: 'plain', text: 'Option A' }, isCorrect: true, weight: 1, feedback: null },
{ text: { format: 'plain', text: 'Option B' }, isCorrect: false, weight: 0, feedback: null },
],
};
const mockQuestionType: QuestionType = {
question: mockQuestion,
image: 'sample-image-url',
};
describe('QuestionType', () => {
test('has the expected structure', () => {
expect(mockQuestionType).toEqual(expect.objectContaining({
question: expect.any(Object),
image: expect.any(String),
}));
expect(mockQuestionType.question).toEqual(expect.objectContaining({
id: expect.any(String),
type: expect.any(String),
stem: expect.objectContaining({
format: expect.any(String),
text: expect.any(String),
}),
title: expect.any(String),
hasEmbeddedAnswers: expect.any(Boolean),
globalFeedback: expect.any(Object),
choices: expect.any(Array),
}));
});
});

View file

@ -0,0 +1,40 @@
/*//QuizType.test.tsx
import { QuizType } from "../../Types/QuizType";
export function isQuizValid(quiz: QuizType): boolean {
return quiz.title.length > 0 && quiz.content.length > 0;
}
describe('isQuizValid function', () => {
it('returns true for a valid quiz', () => {
const validQuiz: QuizType = {
_id: '1',
title: 'Sample Quiz',
content: ['Question 1', 'Question 2'],
};
const result = isQuizValid(validQuiz);
expect(result).toBe(true);
});
it('returns false for an invalid quiz with an empty title', () => {
const invalidQuiz: QuizType = {
_id: '2',
title: '',
content: ['Question 1', 'Question 2'],
};
const result = isQuizValid(invalidQuiz);
expect(result).toBe(false);
});
it('returns false for an invalid quiz with no questions', () => {
const invalidQuiz: QuizType = {
_id: '3',
title: 'Sample Quiz',
content: [],
};
const result = isQuizValid(invalidQuiz);
expect(result).toBe(false);
});
});*/

View file

@ -0,0 +1,15 @@
//UserTyper.test.tsx
import { UserType } from "../../Types/UserType";
const user : UserType = {
name: 'Student',
id: '123'
}
describe('UserType', () => {
test('creates a user with name and id', () => {
expect(user.name).toBe('Student');
expect(user.id).toBe('123');
});
});

View file

@ -0,0 +1,55 @@
// Modal.test.tsx
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import ConfirmDialog from '../../../components/ConfirmDialog/ConfirmDialog';
describe('ConfirmDialog Component', () => {
const mockOnConfirm = jest.fn();
const mockOnCancel = jest.fn();
const mockOnOptionalInputChange = jest.fn();
const sampleProps = {
open: true,
title: 'Sample Modal Title',
message: 'Sample Modal Message',
onConfirm: mockOnConfirm,
onCancel: mockOnCancel,
hasOptionalInput: true,
optionalInputValue: 'Optional Input Value',
onOptionalInputChange: mockOnOptionalInputChange
};
beforeEach(() => {
render(<ConfirmDialog {...sampleProps} />);
});
it('renders correctly', () => {
expect(screen.getByText('Sample Modal Title')).toBeInTheDocument();
expect(screen.getByText('Sample Modal Message')).toBeInTheDocument();
const optionalInput = screen.getByTestId('optional-input') as HTMLInputElement;
expect(optionalInput).toBeInTheDocument();
expect(optionalInput.value).toBe('Optional Input Value');
expect(screen.getByText('Confirmer')).toBeInTheDocument();
expect(screen.getByText('Annuler')).toBeInTheDocument();
});
it('calls onConfirm callback when "Confirmer" button is clicked', () => {
const confirmButton = screen.getByText('Confirmer');
fireEvent.click(confirmButton);
expect(mockOnConfirm).toHaveBeenCalled();
});
it('calls onCancel callback when "Annuler" button is clicked', () => {
const cancelButton = screen.getByText('Annuler');
fireEvent.click(cancelButton);
expect(mockOnCancel).toHaveBeenCalled();
});
it('calls onOptionalInputChange callback when optional input changes', () => {
const optionalInput = screen.getByTestId('optional-input') as HTMLInputElement;
fireEvent.change(optionalInput, { target: { value: 'Updated Value' } });
expect(mockOnOptionalInputChange).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,75 @@
// Editor.test.tsx
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import Editor from '../../../components/Editor/Editor';
describe('Editor Component', () => {
const mockOnEditorChange = jest.fn();
const sampleProps = {
initialValue: 'Sample Initial Value',
onEditorChange: mockOnEditorChange
};
beforeEach(() => {
render(<Editor {...sampleProps} />);
});
it('renders correctly with initial value', () => {
const editorTextarea = screen.getByRole('textbox') as HTMLTextAreaElement;
expect(editorTextarea).toBeInTheDocument();
expect(editorTextarea.value).toBe('Sample Initial Value');
});
it('calls onEditorChange callback when editor value changes', () => {
const editorTextarea = screen.getByRole('textbox') as HTMLTextAreaElement;
fireEvent.change(editorTextarea, { target: { value: 'Updated Value' } });
expect(mockOnEditorChange).toHaveBeenCalledWith('Updated Value');
});
it('updates editor value when initialValue prop changes', () => {
const updatedProps = {
initialValue: 'Updated Initial Value',
onEditorChange: mockOnEditorChange
};
render(<Editor {...updatedProps} />);
const editorTextareas = screen.getAllByRole('textbox') as HTMLTextAreaElement[];
const editorTextarea = editorTextareas[1];
expect(editorTextarea.value).toBe('Updated Initial Value');
});
test('should call change text with the correct value on textarea change', () => {
const updatedProps = {
initialValue: 'Updated Initial Value',
onEditorChange: mockOnEditorChange
};
render(<Editor {...updatedProps} />);
const editorTextareas = screen.getAllByRole('textbox') as HTMLTextAreaElement[];
const editorTextarea = editorTextareas[1];
fireEvent.change(editorTextarea, { target: { value: 'New value' } });
expect(editorTextarea.value).toBe('New value');
});
test('should call onEditorChange with an empty string if textarea value is falsy', () => {
const updatedProps = {
initialValue: 'Updated Initial Value',
onEditorChange: mockOnEditorChange
};
render(<Editor {...updatedProps} />);
const editorTextareas = screen.getAllByRole('textbox') as HTMLTextAreaElement[];
const editorTextarea = editorTextareas[1];
fireEvent.change(editorTextarea, { target: { value: '' } });
expect(editorTextarea.value).toBe('');
});
});

View file

@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import GIFTTemplatePreview from '../../../components/GiftTemplate/GIFTTemplatePreview';
describe('GIFTTemplatePreview Component', () => {
test('renders error message when questions contain invalid syntax', () => {
render(<GIFTTemplatePreview questions={['Invalid GIFT syntax']} />);
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');
expect(previewContainer).toBeInTheDocument();
});
test('hides answers when hideAnswers prop is true', () => {
const questions = [
'Question 1 { A | B | C }',
'Question 2 { D | E | F }',
];
render(<GIFTTemplatePreview questions={questions} hideAnswers />);
const previewContainer = screen.getByTestId('preview-container');
expect(previewContainer).toBeInTheDocument();
});
it('renders images correctly', () => {
const questions = [
'Question 1',
'<img src="image1.jpg" alt="Image 1">',
'Question 2',
'<img src="image2.jpg" alt="Image 2">',
];
const { getByAltText } = render(<GIFTTemplatePreview questions={questions} />);
const image1 = getByAltText('Image 1');
const image2 = getByAltText('Image 2');
expect(image1).toBeInTheDocument();
expect(image2).toBeInTheDocument();
});
it('renders non-images correctly', () => {
const questions = ['Question 1', 'Question 2'];
const { queryByAltText } = render(<GIFTTemplatePreview questions={questions} />);
const image1 = queryByAltText('Image 1');
const image2 = queryByAltText('Image 2');
expect(image1).toBeNull();
expect(image2).toBeNull();
});
});

View file

@ -0,0 +1,35 @@
//color.test.tsx
import { colors } from "../../../../components/GiftTemplate/constants";
describe('Colors object', () => {
test('All colors are defined', () => {
expect(colors.red100).toBeDefined();
expect(colors.red300).toBeDefined();
expect(colors.red700).toBeDefined();
expect(colors.redGray800).toBeDefined();
expect(colors.beige100).toBeDefined();
expect(colors.beige300).toBeDefined();
expect(colors.beige400).toBeDefined();
expect(colors.beige500).toBeDefined();
expect(colors.beige600).toBeDefined();
expect(colors.beige900).toBeDefined();
expect(colors.beigeGray800).toBeDefined();
expect(colors.green100).toBeDefined();
expect(colors.green300).toBeDefined();
expect(colors.green400).toBeDefined();
expect(colors.green500).toBeDefined();
expect(colors.green600).toBeDefined();
expect(colors.green700).toBeDefined();
expect(colors.greenGray500).toBeDefined();
expect(colors.greenGray600).toBeDefined();
expect(colors.greenGray700).toBeDefined();
expect(colors.teal400).toBeDefined();
expect(colors.teal500).toBeDefined();
expect(colors.teal600).toBeDefined();
expect(colors.teal700).toBeDefined();
expect(colors.blue).toBe('#5271FF');
expect(colors.success).toBe('hsl(120, 39%, 54%)');
expect(colors.danger).toBe('hsl(2, 64%, 58%)');
expect(colors.white).toBe('hsl(0, 0%, 100%)');
});
});

View file

@ -0,0 +1,34 @@
//styles.test.tsx
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ParagraphStyle } from '../../../../components/GiftTemplate/constants';
describe('ParagraphStyle', () => {
test('applies styles correctly', () => {
const theme = 'light';
const paragraphText = 'Test paragraph';
const styles = ParagraphStyle(theme);
const { container } = render(
<p style={convertStylesToObject(styles)}>{paragraphText}</p>
);
const paragraphElement = container.firstChild;
expect(paragraphElement).toHaveStyle(`color: rgb(0, 0, 0);`);
});
});
function convertStylesToObject(styles: string): React.CSSProperties {
const styleObject: React.CSSProperties = {};
styles.split(';').forEach((style) => {
const [property, value] = style.split(':');
if (property && value) {
(styleObject as any)[property.trim()] = value.trim();
}
});
return styleObject;
}

View file

@ -0,0 +1,16 @@
import '@testing-library/jest-dom';
import { theme } from '../../../../components/GiftTemplate/constants/theme';
import { colors } from '../../../../components/GiftTemplate/constants/colors';
describe('Theme', () => {
test('returns correct light color', () => {
const lightColor = theme('light', 'gray500', 'black500');
expect(lightColor).toBe(colors.gray500);
});
test('returns correct dark color', () => {
const darkColor = theme('dark', 'gray500', 'black500');
expect(darkColor).toBe(colors.black500);
});
});

View file

@ -0,0 +1,35 @@
import { render } from '@testing-library/react';
import '@testing-library/jest-dom';
import AnswerIcon from '../../../../components/GiftTemplate/templates/AnswerIcon';
describe('AnswerIcon', () => {
test('renders correct icon when correct is true', () => {
const { container } = render(<div dangerouslySetInnerHTML={{ __html: AnswerIcon({ correct: true }) }} />);
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
expect(svgElement).toHaveStyle(`
vertical-align: text-bottom;
display: inline-block;
margin-left: 0.1rem;
margin-right: 0.2rem;
width: 1em;
color: rgb(92, 92, 92);
`);
});
test('renders incorrect icon when correct is false', () => {
const { container } = render(<div dangerouslySetInnerHTML={{ __html: AnswerIcon({ correct: false }) }} />);
const svgElement = container.querySelector('svg');
expect(svgElement).toBeInTheDocument();
expect(svgElement).toHaveStyle(`
vertical-align: text-bottom;
display: inline-block;
margin-left: 0.1rem;
margin-right: 0.2rem;
width: 0.75em;
color: rgb(79, 216, 79);
`);
});
});

View file

@ -0,0 +1,72 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import DragAndDrop from '../../../components/ImportModal/ImportModal';
describe('DragAndDrop Component', () => {
it('renders without errors', () => {
const handleOnClose = jest.fn();
const handleOnImport = jest.fn();
const open = true;
render(
<DragAndDrop
handleOnClose={handleOnClose}
handleOnImport={handleOnImport}
open={open}
selectedFolder="selectedFolder"/>
);
expect(screen.getByText('Importation de quiz')).toBeInTheDocument();
});
test('handles drag and drop', () => {
const handleOnCloseMock = jest.fn();
const handleOnImportMock = jest.fn();
render(
<DragAndDrop
handleOnClose={handleOnCloseMock}
handleOnImport={handleOnImportMock}
open={true}
selectedFolder="selectedFolder"
/>
);
const dropZone = screen.getByText(/Déposer des fichiers ici/i);
fireEvent.dragEnter(dropZone);
fireEvent.dragOver(dropZone);
fireEvent.drop(dropZone, { dataTransfer: { files: [new File([''], 'sample.txt')] } });
expect(screen.getByText('📄')).toBeInTheDocument();
expect(screen.getByText('sample.txt')).toBeInTheDocument();
});
it('handles cancel button correctly', () => {
const handleOnClose = jest.fn();
const handleOnImport = jest.fn();
const open = true;
const { container } = render(
<DragAndDrop handleOnClose={handleOnClose} handleOnImport={handleOnImport} open={open}
selectedFolder="selectedFolder" />
);
const file = new File(['file content'], 'example.txt', { type: 'text/plain' });
fireEvent.change(screen.getByText( /cliquez pour ouvrir l'explorateur des fichiers/i), {
target: { files: [file] },
});
fireEvent.click(screen.getByText('Annuler'));
expect(container.querySelector('.file-container')).not.toBeInTheDocument();
});
it('handles import correctly', async () => {
const handleOnCloseMock = jest.fn();
const handleOnImportMock = jest.fn();
render(
<DragAndDrop
handleOnClose={handleOnCloseMock}
handleOnImport={handleOnImportMock}
open={true}
selectedFolder="selectedFolder"
/>
);
const file = new File(['file content'], 'example.txt', { type: 'text/plain' });
fireEvent.change(screen.getByText( /Importer/i), {
target: { files: [file] },
});
});
});

View file

@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import LaunchQuizDialog from '../../../components/LaunchQuizDialog/LaunchQuizDialog';
// Mock the functions passed as props
const mockHandleOnClose = jest.fn();
const mockLaunchQuiz = jest.fn();
const mockSetQuizMode = jest.fn();
const renderComponent = (open: boolean) => {
render(
<LaunchQuizDialog
open={open}
handleOnClose={mockHandleOnClose}
launchQuiz={mockLaunchQuiz}
setQuizMode={mockSetQuizMode}
/>
);
};
describe('LaunchQuizDialog', () => {
it('renders with correct title', () => {
renderComponent(true);
expect(screen.getByText('Options de lancement du quiz')).toBeInTheDocument();
});
it('renders radio buttons for teacher and student modes', () => {
renderComponent(true);
expect(screen.getByLabelText('Rythme du professeur')).toBeInTheDocument();
expect(screen.getByLabelText('Rythme de l\'étudiant')).toBeInTheDocument();
});
it('calls handleOnClose when "Annuler" button is clicked', () => {
renderComponent(true);
fireEvent.click(screen.getByText('Annuler'));
expect(mockHandleOnClose).toHaveBeenCalled();
});
it('calls launchQuiz when "Lancer" button is clicked', () => {
renderComponent(true);
fireEvent.click(screen.getByText('Lancer'));
expect(mockLaunchQuiz).toHaveBeenCalled();
});
it('does not render when open is false', () => {
renderComponent(false);
expect(screen.queryByText('Options de lancement du quiz')).toBeNull();
});
});

View file

@ -0,0 +1,13 @@
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import LoadingCircle from '../../../components/LoadingCircle/LoadingCircle';
describe('LoadingCircle', () => {
it('displays the provided text correctly', () => {
const text = 'Veuillez attendre la connexion au serveur...';
render(<LoadingCircle text={text} />);
expect(screen.getByText(text)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,44 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import MultipleChoiceQuestion from '../../../../components/Questions/MultipleChoiceQuestion/MultipleChoiceQuestion';
describe('MultipleChoiceQuestion', () => {
const mockHandleOnSubmitAnswer = jest.fn();
const choices = [
{ feedback: null, isCorrect: true, text: { format: 'plain', text: 'Choice 1' } },
{ feedback: null, isCorrect: false, text: { format: 'plain', text: 'Choice 2' } }
];
beforeEach(() => {
render(
<MultipleChoiceQuestion
globalFeedback="feedback"
questionTitle="Test Question"
choices={choices}
handleOnSubmitAnswer={mockHandleOnSubmitAnswer}
/>
);
});
test('renders the question and choices', () => {
expect(screen.getByText('Test Question')).toBeInTheDocument();
choices.forEach((choice) => {
expect(screen.getByText(choice.text.text)).toBeInTheDocument();
});
});
test('does not submit when no answer is selected', () => {
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleOnSubmitAnswer).not.toHaveBeenCalled();
});
test('submits the selected answer', () => {
const choiceButton = screen.getByText('Choice 1').closest('button');
if (!choiceButton) throw new Error('Choice button not found');
fireEvent.click(choiceButton);
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleOnSubmitAnswer).toHaveBeenCalledWith('Choice 1');
});
});

View file

@ -0,0 +1,62 @@
// NumericalQuestion.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import NumericalQuestion from '../../../../components/Questions/NumericalQuestion/NumericalQuestion';
describe('NumericalQuestion Component', () => {
const mockHandleSubmitAnswer = jest.fn();
const sampleProps = {
questionTitle: 'Sample Question',
correctAnswers: {
numberHigh: 10,
numberLow: 5,
type: 'high-low'
},
handleOnSubmitAnswer: mockHandleSubmitAnswer,
showAnswer: false
};
beforeEach(() => {
render(<NumericalQuestion {...sampleProps} />);
});
it('renders correctly', () => {
expect(screen.getByText('Sample Question')).toBeInTheDocument();
expect(screen.getByTestId('number-input')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
});
it('handles input change correctly', () => {
const inputElement = screen.getByTestId('number-input') as HTMLInputElement;
fireEvent.change(inputElement, { target: { value: '7' } });
expect(inputElement.value).toBe('7');
});
it('Submit button should be disable if nothing is entered', () => {
const submitButton = screen.getByText('Répondre');
expect(submitButton).toBeDisabled();
});
it('not submited answer if nothing is entered', () => {
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).not.toHaveBeenCalled();
});
it('submits answer correctly', () => {
const inputElement = screen.getByTestId('number-input');
const submitButton = screen.getByText('Répondre');
fireEvent.change(inputElement, { target: { value: '7' } });
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(7);
});
});

View file

@ -0,0 +1,140 @@
// Question.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import Questions from '../../../components/Questions/Question';
import { GIFTQuestion } from 'gift-pegjs';
//
describe('Questions Component', () => {
const mockHandleSubmitAnswer = jest.fn();
const sampleTrueFalseQuestion: GIFTQuestion = {
type: 'TF',
stem: { format: 'plain', text: 'Sample True/False Question' },
isTrue: true,
incorrectFeedback: null,
correctFeedback: null,
title: 'True/False Question',
hasEmbeddedAnswers: false,
globalFeedback: null,
};
const sampleMultipleChoiceQuestion: GIFTQuestion = {
type: 'MC',
stem: { format: 'plain', text: 'Sample Multiple Choice Question' },
title: 'Multiple Choice Question',
hasEmbeddedAnswers: false,
globalFeedback: null,
choices: [
{ feedback: null, isCorrect: true, text: { format: 'plain', text: 'Choice 1' }, weight: 1 },
{ feedback: null, isCorrect: false, text: { format: 'plain', text: 'Choice 2' }, weight: 0 },
],
};
const sampleNumericalQuestion: GIFTQuestion = {
type: 'Numerical',
stem: { format: 'plain', text: 'Sample Numerical Question' },
title: 'Numerical Question',
hasEmbeddedAnswers: false,
globalFeedback: null,
choices: { numberHigh: 10, numberLow: 5, type: 'high-low' },
};
const sampleShortAnswerQuestion: GIFTQuestion = {
type: 'Short',
stem: { format: 'plain', text: 'Sample short answer question' },
title: 'Short Answer Question Title',
hasEmbeddedAnswers: false,
globalFeedback: null,
choices: [
{
feedback: { format: 'html', text: 'Correct answer feedback' },
isCorrect: true,
text: { format: 'html', text: 'Correct Answer' },
weight: 1,
},
{
feedback: { format: 'html', text: 'Incorrect answer feedback' },
isCorrect: false,
text: { format: 'html', text: 'Incorrect Answer' },
weight: 0,
},
],
};
const renderComponent = (question: GIFTQuestion) => {
render(<Questions question={question} handleOnSubmitAnswer={mockHandleSubmitAnswer} />);
};
it('renders correctly for True/False question', () => {
renderComponent(sampleTrueFalseQuestion);
expect(screen.getByText('Sample True/False Question')).toBeInTheDocument();
expect(screen.getByText('Vrai')).toBeInTheDocument();
expect(screen.getByText('Faux')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
});
it('renders correctly for Multiple Choice question', () => {
renderComponent(sampleMultipleChoiceQuestion);
expect(screen.getByText('Sample Multiple Choice Question')).toBeInTheDocument();
expect(screen.getByText('Choice 1')).toBeInTheDocument();
expect(screen.getByText('Choice 2')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
});
it('handles selection and submission for Multiple Choice question', () => {
renderComponent(sampleMultipleChoiceQuestion);
const choiceButton = screen.getByText('Choice 1').closest('button')!;
fireEvent.click(choiceButton);
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith('Choice 1');
});
it('renders correctly for Numerical question', () => {
renderComponent(sampleNumericalQuestion);
expect(screen.getByText('Sample Numerical Question')).toBeInTheDocument();
expect(screen.getByTestId('number-input')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
});
it('handles input and submission for Numerical question', () => {
renderComponent(sampleNumericalQuestion);
const inputElement = screen.getByTestId('number-input') as HTMLInputElement;
fireEvent.change(inputElement, { target: { value: '7' } });
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(7);
});
it('renders correctly for Short Answer question', () => {
renderComponent(sampleShortAnswerQuestion);
expect(screen.getByText('Sample short answer question')).toBeInTheDocument();
expect(screen.getByTestId('text-input')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
});
it('handles input and submission for Short Answer question', () => {
renderComponent(sampleShortAnswerQuestion);
const inputElement = screen.getByTestId('text-input') as HTMLInputElement;
fireEvent.change(inputElement, { target: { value: 'User Input' } });
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith('User Input');
});
});

View file

@ -0,0 +1,80 @@
// ShortAnswerQuestion.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import ShortAnswerQuestion from '../../../../components/Questions/ShortAnswerQuestion/ShortAnswerQuestion';
describe('ShortAnswerQuestion Component', () => {
const mockHandleSubmitAnswer = jest.fn();
const sampleProps = {
questionTitle: 'Sample Question',
choices: [
{
feedback: {
format: 'text',
text: 'Correct answer feedback'
},
isCorrect: true,
text: {
format: 'text',
text: 'Correct Answer'
}
},
{
feedback: null,
isCorrect: false,
text: {
format: 'text',
text: 'Incorrect Answer'
}
}
],
handleOnSubmitAnswer: mockHandleSubmitAnswer,
showAnswer: false
};
beforeEach(() => {
render(<ShortAnswerQuestion {...sampleProps} />);
});
it('renders correctly', () => {
expect(screen.getByText('Sample Question')).toBeInTheDocument();
expect(screen.getByTestId('text-input')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
});
it('handles input change correctly', () => {
const inputElement = screen.getByTestId('text-input') as HTMLInputElement;
fireEvent.change(inputElement, { target: { value: 'User Input' } });
expect(inputElement.value).toBe('User Input');
});
it('Submit button should be disable if nothing is entered', () => {
const submitButton = screen.getByText('Répondre');
expect(submitButton).toBeDisabled();
});
it('not submited answer if nothing is entered', () => {
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).not.toHaveBeenCalled();
});
it('submits answer correctly', () => {
const inputElement = screen.getByTestId('text-input') as HTMLInputElement;
const submitButton = screen.getByText('Répondre');
fireEvent.change(inputElement, { target: { value: 'User Input' } });
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith('User Input');
});
});

View file

@ -0,0 +1,64 @@
// TrueFalseQuestion.test.tsx
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import TrueFalseQuestion from '../../../../components/Questions/TrueFalseQuestion/TrueFalseQuestion';
describe('TrueFalseQuestion Component', () => {
const mockHandleSubmitAnswer = jest.fn();
const sampleProps = {
questionTitle: 'Sample True/False Question',
correctAnswer: true,
handleOnSubmitAnswer: mockHandleSubmitAnswer,
showAnswer: false
};
beforeEach(() => {
render(<TrueFalseQuestion {...sampleProps} />);
});
it('renders correctly', () => {
expect(screen.getByText('Sample True/False Question')).toBeInTheDocument();
expect(screen.getByText('Vrai')).toBeInTheDocument();
expect(screen.getByText('Faux')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
});
it('Submit button should be disabled if no option is selected', () => {
const submitButton = screen.getByText('Répondre');
expect(submitButton).toBeDisabled();
});
it('not submit answer if no option is selected', () => {
const submitButton = screen.getByText('Répondre');
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).not.toHaveBeenCalled();
});
it('submits answer correctly for True', () => {
const trueButton = screen.getByText('Vrai');
const submitButton = screen.getByText('Répondre');
fireEvent.click(trueButton);
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(true);
});
it('submits answer correctly for False', () => {
const falseButton = screen.getByText('Faux');
const submitButton = screen.getByText('Répondre');
fireEvent.click(falseButton);
fireEvent.click(submitButton);
expect(mockHandleSubmitAnswer).toHaveBeenCalledWith(false);
});
});

View file

@ -0,0 +1,45 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import ReturnButton from '../../../components/ReturnButton/ReturnButton';
import { useNavigate } from 'react-router-dom';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn()
}));
describe('ReturnButton', () => {
test('navigates back when askConfirm is false', () => {
const navigateMock = jest.fn();
(useNavigate as jest.Mock).mockReturnValue(navigateMock);
render(<ReturnButton askConfirm={false} />);
fireEvent.click(screen.getByText('Retour'));
expect(navigateMock).toHaveBeenCalledWith(-1);
});
test('shows confirmation modal when askConfirm is true', () => {
render(<ReturnButton askConfirm={true} />);
fireEvent.click(screen.getByText('Retour'));
const confirmButton = screen.getByTestId('modal-confirm-button');
expect(confirmButton).toBeInTheDocument();
});
/*test('navigates back after confirming in the modal', () => {
const navigateMock = jest.fn();
(useNavigate as jest.Mock).mockReturnValue(navigateMock);
render(<ReturnButton askConfirm={true} />);
fireEvent.click(screen.getByText('Retour'));
const confirmButton = screen.getByTestId('modal-confirm-button');
fireEvent.click(confirmButton);
expect(navigateMock).toHaveBeenCalledWith(-1);
});*/
test('cancels navigation when canceling in the modal', () => {
const navigateMock = jest.fn();
(useNavigate as jest.Mock).mockReturnValue(navigateMock);
render(<ReturnButton askConfirm={true} />);
fireEvent.click(screen.getByText('Retour'));
fireEvent.click(screen.getByText('Annuler'));
expect(navigateMock).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,41 @@
// Importez le type UserType s'il n'est pas déjà importé
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import UserWaitPage from '../../../components/UserWaitPage/UserWaitPage';
describe('UserWaitPage Component', () => {
const mockUsers = [
{ id: '1', name: 'User1' },
{ id: '2', name: 'User2' },
{ id: '3', name: 'User3' },
];
const mockProps = {
users: mockUsers,
launchQuiz: jest.fn(),
roomName: 'Test Room',
setQuizMode: jest.fn(),
};
test('renders UserWaitPage with correct content', () => {
render(<UserWaitPage {...mockProps} />);
expect(screen.getByText(/Salle: Test Room/)).toBeInTheDocument();
const launchButton = screen.getByRole('button', { name: /Lancer/i });
expect(launchButton).toBeInTheDocument();
mockUsers.forEach((user) => {
expect(screen.getByText(user.name)).toBeInTheDocument();
});
});
test('clicking on "Lancer" button opens LaunchQuizDialog', () => {
render(<UserWaitPage {...mockProps} />);
fireEvent.click(screen.getByRole('button', { name: /Lancer/i }));
expect(screen.getByRole('dialog')).toBeInTheDocument();
});
})

View file

@ -0,0 +1,35 @@
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { BrowserRouter } from 'react-router-dom';
import Home from '../../../pages/Home/Home';
describe('Home', () => {
it('renders Home component with page title and buttons', () => {
render(
<BrowserRouter>
<Home />
</BrowserRouter>
);
expect(screen.getByText(/Espace\s*étudiant/)).toBeInTheDocument();
expect(screen.getByText(/Espace\s*enseignant/)).toBeInTheDocument();
});
it('navigates to the correct routes when student and teacher buttons are clicked', () => {
render(
<BrowserRouter>
<Home />
</BrowserRouter>
);
const studentButton = screen.getByText(/Espace\s*étudiant/);
expect(studentButton).toBeInTheDocument();
fireEvent.click(studentButton);
expect(window.location.pathname).toBe('/student/join-room');
const teacherButton = screen.getByText(/Espace\s*enseignant/);
expect(teacherButton).toBeInTheDocument();
fireEvent.click(teacherButton);
expect(window.location.pathname).toBe('/teacher/dashboard');
});
});

View file

@ -0,0 +1,127 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { QuestionType } from '../../../../Types/QuestionType';
import StudentModeQuiz from '../../../../components/StudentModeQuiz/StudentModeQuiz';
describe('StudentModeQuiz', () => {
const mockQuestions: QuestionType[] = [
{
question: {
id: '1',
type: 'MC',
stem: { format: 'plain', text: 'Sample Question 1' },
title: 'Sample Question 1',
hasEmbeddedAnswers: false,
globalFeedback: null,
choices: [
{ text: { format: 'plain', text: 'Option A' }, isCorrect: true, weight: 1, feedback: null },
{ text: { format: 'plain', text: 'Option B' }, isCorrect: false, weight: 0, feedback: null },
],
},
image: '<img src="sample-image-url" alt="Sample Image" />',
},
{
question: {
id: '2',
type: 'TF',
stem: { format: 'plain', text: 'Sample Question 2' },
isTrue: true,
incorrectFeedback: null,
correctFeedback: null,
title: 'Question 2',
hasEmbeddedAnswers: false,
globalFeedback: null,
},
image: 'sample-image-url-2',
},
];
const mockSubmitAnswer = jest.fn();
const mockDisconnectWebSocket = jest.fn();
test('renders the initial question', async () => {
render(
<StudentModeQuiz
questions={mockQuestions}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
);
expect(screen.getByText('Sample Question 1')).toBeInTheDocument();
expect(screen.getByText('Option A')).toBeInTheDocument();
expect(screen.getByText('Option B')).toBeInTheDocument();
expect(screen.getByText('Déconnexion')).toBeInTheDocument();
});
test('handles answer submission text', () => {
render(
<StudentModeQuiz
questions={mockQuestions}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
);
fireEvent.click(screen.getByText('Option A'));
fireEvent.click(screen.getByText('Répondre'));
expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', '1');
});
test('handles disconnect button click', () => {
render(
<StudentModeQuiz
questions={mockQuestions}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
);
fireEvent.click(screen.getByText('Déconnexion'));
expect(mockDisconnectWebSocket).toHaveBeenCalled();
});
test('navigates to the next question', () => {
render(
<StudentModeQuiz
questions={mockQuestions}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
);
fireEvent.click(screen.getByText('Option A'));
fireEvent.click(screen.getByText('Répondre'));
fireEvent.click(screen.getByText('Question suivante'));
expect(screen.getByText('Sample Question 2')).toBeInTheDocument();
expect(screen.getByText('T')).toBeInTheDocument();
});
test('navigates to the previous question', () => {
render(
<StudentModeQuiz
questions={mockQuestions}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
);
fireEvent.click(screen.getByText('Option A'));
fireEvent.click(screen.getByText('Répondre'));
fireEvent.click(screen.getByText('Question précédente'));
expect(screen.getByText('Sample Question 1')).toBeInTheDocument();
expect(screen.getByText('Option B')).toBeInTheDocument();
});
});

View file

@ -0,0 +1,57 @@
//TeacherModeQuiz.test.tsx
import { render, screen, fireEvent} from '@testing-library/react';
import '@testing-library/jest-dom';
import { GIFTQuestion } from 'gift-pegjs';
import TeacherModeQuiz from '../../../../components/TeacherModeQuiz/TeacherModeQuiz';
describe('TeacherModeQuiz', () => {
const mockQuestion: GIFTQuestion = {
id: '1',
type: 'MC',
stem: { format: 'plain', text: 'Sample Question' },
title: 'Sample Question',
hasEmbeddedAnswers: false,
globalFeedback: null,
choices: [
{ text: { format: 'plain', text: 'Option A' }, isCorrect: true, weight: 1, feedback: null },
{ text: { format: 'plain', text: 'Option B' }, isCorrect: false, weight: 0, feedback: null },
],
};
const mockSubmitAnswer = jest.fn();
const mockDisconnectWebSocket = jest.fn();
beforeEach(() => {
render(
<TeacherModeQuiz
questionInfos={{ question: mockQuestion, image: 'sample-image-url' }}
submitAnswer={mockSubmitAnswer}
disconnectWebSocket={mockDisconnectWebSocket}
/>
);
});
test('renders the initial question', () => {
expect(screen.getByText('Question 1')).toBeInTheDocument();
expect(screen.getByText('Sample Question')).toBeInTheDocument();
expect(screen.getByText('Option A')).toBeInTheDocument();
expect(screen.getByText('Option B')).toBeInTheDocument();
expect(screen.getByText('Déconnexion')).toBeInTheDocument();
expect(screen.getByText('Répondre')).toBeInTheDocument();
});
test('handles answer submission and displays wait text', () => {
fireEvent.click(screen.getByText('Option A'));
fireEvent.click(screen.getByText('Répondre'));
expect(mockSubmitAnswer).toHaveBeenCalledWith('Option A', '1');
expect(screen.getByText('En attente pour la prochaine question...')).toBeInTheDocument();
});
test('handles disconnect button click', () => {
fireEvent.click(screen.getByText('Déconnexion'));
expect(mockDisconnectWebSocket).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,67 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';
import Dashboard from '../../../../pages/Teacher/Dashboard/Dashboard';
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => (store[key] = value.toString()),
clear: () => (store = {}),
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
describe('Dashboard Component', () => {
beforeEach(() => {
localStorage.setItem('quizzes', JSON.stringify([]));
});
test('renders Dashboard with default state', () => {
render(
<MemoryRouter>
<Dashboard />
</MemoryRouter>
);
expect(screen.getByText(/Tableau de bord/i)).toBeInTheDocument();
});
test('adds a quiz and checks if it is displayed', () => {
const mockQuizzes = [
{
id: '1',
title: 'Sample Quiz',
questions: ['Question 1?', 'Question 2?'],
},
];
localStorage.setItem('quizzes', JSON.stringify(mockQuizzes));
render(
<MemoryRouter>
<Dashboard />
</MemoryRouter>
);
expect(screen.getByText(/Sample Quiz/i)).toBeInTheDocument();
});
test('opens ImportModal when "Importer" button is clicked', () => {
render(
<MemoryRouter>
<Dashboard />
</MemoryRouter>
);
fireEvent.click(screen.getByText(/Importer/i));
expect(screen.getByText(/Importation de quiz/i)).toBeInTheDocument();
});
});

View file

@ -0,0 +1,60 @@
import { render, screen, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom';
import QuizForm from '../../../../pages/Teacher/EditorQuiz/EditorQuiz';
import { waitFor } from '@testing-library/react';
const localStorageMock = (() => {
let store: Record<string, string> = {};
return {
getItem: (key: string) => store[key] || null,
setItem: (key: string, value: string) => (store[key] = value.toString()),
clear: () => (store = {}),
};
})();
Object.defineProperty(window, 'localStorage', { value: localStorageMock });
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: jest.fn(),
}));
describe('QuizForm Component', () => {
test('renders QuizForm with default state for a new quiz', () => {
render(
<MemoryRouter initialEntries={['/teacher/editor-quiz/new']}>
<QuizForm />
</MemoryRouter>
);
expect(screen.queryByText('Éditeur de quiz')).toBeInTheDocument();
expect(screen.queryByText('Éditeur')).toBeInTheDocument();
expect(screen.queryByText('Prévisualisation')).toBeInTheDocument();
});
test('renders QuizForm for a new quiz', async () => {
render(
<MemoryRouter initialEntries={['/teacher/editor-quiz']}>
<QuizForm />
</MemoryRouter>
);
expect(screen.getByText(/Éditeur de quiz/i)).toBeInTheDocument();
const editorTextArea = screen.getByRole('textbox');
fireEvent.change(editorTextArea, { target: { value: 'Sample question?' } });
await waitFor(() => {
const sampleQuestionElements = screen.queryAllByText(/Sample question\?/i);
expect(sampleQuestionElements.length).toBeGreaterThan(0);
});
const saveButton = screen.getByText(/Enregistrer/i);
fireEvent.click(saveButton);
await waitFor(() => {
expect(screen.getByText(/Sauvegarder le questionnaire/i)).toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,39 @@
import { QuestionService } from "../../services/QuestionService";
describe('QuestionService', () => {
describe('getImage', () => {
it('should return empty string for text without image tag', () => {
const text = 'This is a sample text without an image tag.';
const imageUrl = QuestionService.getImage(text);
expect(imageUrl).toBe('');
});
it('should return the image tag from the text', () => {
const text = 'This is a sample text with an <img src="image.jpg" alt="Sample Image" /> tag.';
const imageUrl = QuestionService.getImage(text);
expect(imageUrl).toBe('<img src="image.jpg" alt="Sample Image" />');
});
});
describe('getImageSource', () => {
it('should return the image source from the image tag in the text', () => {
const text = '<img src="image.jpg" alt="Sample Image" />';
const imageUrl = QuestionService.getImageSource(text);
expect(imageUrl).toBe('src="image.jpg" alt="Sample Image" /');
});
});
describe('ignoreImgTags', () => {
it('should return the same text if it does not contain an image tag', () => {
const text = 'This is a sample text without an image tag.';
const result = QuestionService.ignoreImgTags(text);
expect(result).toBe(text);
});
it('should remove the image tag from the text', () => {
const text = 'This is a sample text with an <img src="image.jpg" alt="Sample Image" /> tag.';
const result = QuestionService.ignoreImgTags(text);
expect(result).toBe('This is a sample text with an tag.');
});
});
});

View file

@ -0,0 +1,65 @@
/*import { QuizService } from '../../services/QuizService';
import { QuizType } from '../../Types/QuizType';
// we need to mock localStorage for this test
if (typeof window === 'undefined') {
global.window = {} as Window & typeof globalThis;
}
const localStorageMock = (() => {
let store: { [key: string]: string } = {};
return {
getItem(key: string) {
return store[key] || null;
},
setItem(key: string, value: string) {
store[key] = value.toString();
},
removeItem(key: string) {
delete store[key];
},
clear() {
store = {};
}
};
})();
Object.defineProperty(window, 'localStorage', {
value: localStorageMock
});
/*describe('QuizService', () => {
const mockQuizzes: QuizType[] = [
{ _id: 'quiz1', title: 'Quiz One', content: ['Q1', 'Q2'] },
{ _id: 'quiz2', title: 'Quiz Two', content: ['Q3', 'Q4'] }
];
beforeEach(() => {
localStorageMock.setItem('quizzes', JSON.stringify(mockQuizzes));
});
afterEach(() => {
localStorageMock.removeItem('quizzes');
});
test('should return quiz for valid id', () => {
const quiz = QuizService.getQuizById('quiz1', localStorageMock);
expect(quiz).toEqual(mockQuizzes[0]);
});
test('should return undefined for invalid id', () => {
const quiz = QuizService.getQuizById('nonexistent', localStorageMock);
expect(quiz).toBeUndefined();
});
test('should return undefined for undefined id', () => {
const quiz = QuizService.getQuizById(undefined, localStorageMock);
expect(quiz).toBeUndefined();
});
test('should handle empty localStorage', () => {
localStorageMock.removeItem('quizzes');
const quiz = QuizService.getQuizById('quiz1', localStorageMock);
expect(quiz).toBeUndefined();
});
});*/

View file

@ -0,0 +1,88 @@
//WebsocketService.test.tsx
import WebsocketService from '../../services/WebsocketService';
import { io, Socket } from 'socket.io-client';
import { ENV_VARIABLES } from '../../constants';
jest.mock('socket.io-client');
jest.mock('../../constants', () => ({
ENV_VARIABLES: {
VITE_BACKEND_URL: 'https://ets-glitch-backend.glitch.me/'
}
}));
describe('WebSocketService', () => {
let mockSocket: Partial<Socket>;
beforeEach(() => {
mockSocket = {
emit: jest.fn(),
disconnect: jest.fn(),
connect: jest.fn()
};
(io as jest.Mock).mockReturnValue(mockSocket);
});
afterEach(() => {
jest.restoreAllMocks();
});
test('connect should initialize socket connection', () => {
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
expect(io).toHaveBeenCalled();
expect(WebsocketService['socket']).toBe(mockSocket);
});
test('disconnect should terminate socket connection', () => {
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
expect(WebsocketService['socket']).toBeTruthy();
WebsocketService.disconnect();
expect(mockSocket.disconnect).toHaveBeenCalled();
expect(WebsocketService['socket']).toBeNull();
});
test('createRoom should emit create-room event', () => {
WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.createRoom();
expect(mockSocket.emit).toHaveBeenCalledWith('create-room');
});
test('nextQuestion should emit next-question event with correct parameters', () => {
const roomName = 'testRoom';
const question = { id: 1, text: 'Sample Question' };
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.nextQuestion(roomName, question);
expect(mockSocket.emit).toHaveBeenCalledWith('next-question', { roomName, question });
});
test('launchStudentModeQuiz should emit launch-student-mode event with correct parameters', () => {
const roomName = 'testRoom';
const questions = [{ id: 1, text: 'Sample Question' }];
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.launchStudentModeQuiz(roomName, questions);
expect(mockSocket.emit).toHaveBeenCalledWith('launch-student-mode', {
roomName,
questions
});
});
test('endQuiz should emit end-quiz event with correct parameters', () => {
const roomName = 'testRoom';
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.endQuiz(roomName);
expect(mockSocket.emit).toHaveBeenCalledWith('end-quiz', { roomName });
});
test('joinRoom should emit join-room event with correct parameters', () => {
const enteredRoomName = 'testRoom';
const username = 'testUser';
mockSocket = WebsocketService.connect(ENV_VARIABLES.VITE_BACKEND_URL);
WebsocketService.joinRoom(enteredRoomName, username);
expect(mockSocket.emit).toHaveBeenCalledWith('join-room', { enteredRoomName, username });
});
});

View file

@ -0,0 +1,77 @@
// Modal.tsx
import React from 'react';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
TextField
} from '@mui/material';
type Props = {
open: boolean;
title: string;
message: string;
onConfirm: () => void;
onCancel: () => void;
hasOptionalInput?: boolean;
optionalInputValue?: string;
onOptionalInputChange?: (event: React.ChangeEvent<HTMLInputElement>) => void;
buttonOrderType?: 'normal' | 'warning';
};
const ConfirmDialog: React.FC<Props> = ({
open,
title,
message,
onConfirm,
onCancel,
hasOptionalInput,
optionalInputValue,
onOptionalInputChange,
buttonOrderType = 'normal'
}) => {
return (
<Dialog open={open} onClose={onCancel}>
<DialogTitle sx={{ fontWeight: 'bold', fontSize: 24 }}>{title}</DialogTitle>
<DialogContentText sx={{ padding: '0 1.5rem 0.5rem 1.5rem' }}>
{message}
</DialogContentText>
{hasOptionalInput && (
<DialogContent>
<TextField
id="optional-input"
inputProps={{ 'data-testid': 'optional-input' }}
focused
fullWidth
value={optionalInputValue || ''}
onChange={onOptionalInputChange}
/>
</DialogContent>
)}
<DialogActions>
{buttonOrderType === 'normal' && (
<Button variant="outlined" onClick={onCancel}>
Annuler
</Button>
)}
<Button
variant={buttonOrderType === 'normal' ? 'contained' : 'outlined'}
onClick={onConfirm}
data-testid="modal-confirm-button"
>
Confirmer
</Button>
{buttonOrderType === 'warning' && (
<Button variant="contained" onClick={onCancel}>
Annuler
</Button>
)}
</DialogActions>
</Dialog>
);
};
export default ConfirmDialog;

View file

@ -0,0 +1,66 @@
// GoBackButton.tsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ConfirmDialog from '../ConfirmDialog/ConfirmDialog';
import { Button } from '@mui/material';
import { ChevronLeft } from '@mui/icons-material';
interface Props {
onReturn?: () => void;
askConfirm?: boolean;
message?: string;
}
const DisconnectButton: React.FC<Props> = ({
askConfirm = false,
message = 'Êtes-vous sûr de vouloir quitter la page ?',
onReturn
}) => {
const navigate = useNavigate();
const [showDialog, setShowDialog] = useState(false);
const handleOnReturnButtonClick = () => {
if (askConfirm) {
setShowDialog(true);
} else {
handleOnReturn();
}
};
const handleConfirm = () => {
setShowDialog(false);
handleOnReturn();
};
const handleOnReturn = () => {
if (!!onReturn) {
onReturn();
} else {
navigate(-1);
}
};
return (
<div className='returnButton'>
<Button
variant="text"
startIcon={<ChevronLeft />}
onClick={handleOnReturnButtonClick}
color="primary"
sx={{ marginLeft: '-0.5rem', fontSize: 16 }}
>
Quitter
</Button>
<ConfirmDialog
open={showDialog}
title="Confirmer"
message={message}
onConfirm={handleConfirm}
onCancel={() => setShowDialog(false)}
buttonOrderType="warning"
/>
</div>
);
};
export default DisconnectButton;

View file

@ -0,0 +1,32 @@
// Editor.tsx
import React, { useState, useRef } from 'react';
import './editor.css';
interface EditorProps {
initialValue: string;
onEditorChange: (value: string) => void;
}
const Editor: React.FC<EditorProps> = ({ initialValue, onEditorChange }) => {
const [value, setValue] = useState(initialValue);
const editorRef = useRef<HTMLTextAreaElement | null>(null);
function handleEditorChange(event: React.ChangeEvent<HTMLTextAreaElement>) {
const text = event.target.value;
setValue(text);
onEditorChange(text || '');
}
return (
<div>
<textarea
ref={editorRef}
onChange={handleEditorChange}
value={value}
className="editor"
></textarea>
</div>
);
};
export default Editor;

View file

@ -0,0 +1,9 @@
.editor {
width: 100%;
height: 50vh;
background-color: #f8f9ff;
padding-left: 10px;
padding-top: 10px;
font-size: medium;
resize: none;
}

View file

@ -0,0 +1,25 @@
import * as React from 'react';
import './footer.css';
interface FooterProps {
}
const Footer: React.FC<FooterProps> = ({ }) => {
return (
<div className="footer">
<div className="footer-content">
Réalisé avec à Montréal par des finissantes de l'ETS
</div>
<div className="footer-links">
<a href="https://github.com/louis-antoine-etsmtl/ETS-PFE042-EvalueTonSavoir-Frontend/tree/main">Frontend GitHub</a>
<span className="divider">|</span>
<a href="https://github.com/louis-antoine-etsmtl/ETS-PFE042-EvalueTonSavoir-Backend">Backend GitHub</a>
<span className="divider">|</span>
<a href="https://github.com/louis-antoine-etsmtl/EvalueTonSavoir/wiki">Wiki GitHub</a>
</div>
</div>
);
};
export default Footer;

View file

@ -0,0 +1,23 @@
.footer {
flex-shrink: 0;
padding: 20px;
text-align: center;
}
.footer-content {
margin-bottom: 10px;
}
.footer-links a {
color: #333;
text-decoration: none;
}
.footer-links a:hover {
text-decoration: underline;
}
.divider {
margin: 0 10px;
color: #666;
}

View file

@ -0,0 +1,153 @@
// GiftCheatSheet.tsx
import React from 'react';
import './giftCheatSheet.css';
const GiftCheatSheet: React.FC = () => {
return (
<div className="gift-cheat-sheet">
<h2 className="subtitle">Informations pratiques sur l'éditeur</h2>
<span>
L'éditeur utilise le format GIFT (General Import Format Template) créé pour la
plateforme Moodle afin de générer les quizs. Ci-dessous vous pouvez retrouver la
syntaxe pour chaque type de question ainsi que les champs optionnels :
</span>
<div className="question-type">
<h4>1. Questions Vrai/Faux</h4>
<pre>
<code className="selectable-text">
{'2+2 \\= 4 ? {T\n}// Vous pouvez utiliser les valeurs {T}, {F}, {TRUE} et {FALSE}'}
</code>
</pre>
</div>
<div className="question-type">
<h4>2. Questions à choix multiple</h4>
<pre>
<code className="question-code-block selectable-text">
{
'Quelle ville est la capitale du Canada? {\n~ Toronto\n~ Montréal\n= Ottawa #Bonne réponse!\n}// La bonne réponse est Ottawa'
}
</code>
</pre>
</div>
<div className="question-type">
<h4>3. Questions à choix multiple avec plusieurs réponses</h4>
<pre>
<code className="question-code-block selectable-text">
{
'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} //On utilise le signe ~ pour toutes les réponses. On doit indiquer le pourcentage de chaque réponse'
}
</code>
</pre>
</div>
<div className="question-type">
<h4>4. Questions à reponse courte</h4>
<pre>
<code className="question-code-block selectable-text">
{'Avec quoi ouvre-t-on une porte? { \n= clé \n= clef \n}// Permet de fournir plusieurs bonnes réponses. Note: Les majuscules ne sont pas prises en compte.'}
</code>
</pre>
</div>
<div className="question-type">
<h4> 5. Questions numériques </h4>
<pre>
<code className="question-code-block selectable-text">
{
'Question {#=Nombre\n} //OU \nQuestion {#=Nombre:Tolérance\n} //OU \nQuestion {#=PetitNombre..GrandNombre\n} // La tolérance est un pourcentage. La réponse doit être comprise entre PetitNombre et GrandNombre'
}
</code>
</pre>
</div>
<div className="question-type">
<h4> 6. Paramètres optionnels </h4>
<pre>
<code className="question-code-block selectable-text">
{'::Titre:: '}
<span className="code-comment selectable-text">
{' // Ajoute un titre à une question'}
</span>
<br />
{'# Feedback '}
<span className="code-comment selectable-text">
{' // Feedback pour UNE réponse'}
</span>
<br />
{'// Commentaire '}
<span className="code-comment selectable-text">
{' // Commentaire non apparent'}
</span>
<br />
{'#### Feedback général '}
<span className="code-comment selectable-text">
{' // Feedback général pour une question'}
</span>
<br />
{'%50% '}
<span className="code-comment selectable-text">
{" // Poids d'une réponse (peut être négatif)"}
</span>
</code>
</pre>
</div>
<div className="question-type">
<h4> 7. Paramètres optionnels </h4>
<p>
Si vous souhaitez utiliser certains caractères spéciaux dans vos énoncés,
réponses ou feedback, vous devez 'échapper' ces derniers en ajoutant un \
devant:
</p>
<pre>
<code className="question-code-block selectable-text">
{'\\~ \n\\= \n\\# \n\\{ \n\\} \n\\:'}
</code>
</pre>
</div>
<div className="question-type">
<h4> 8. LaTeX </h4>
<p>
Le format LaTeX est supporté dans cette application. Vous devez cependant penser
à 'échapper' les caractères spéciaux mentionnés plus haut.
</p>
<p>Exemple d'équation:</p>
<pre>
<code className="question-code-block">{'$$x\\= \\frac\\{y^2\\}\\{4\\}$$'}</code>
</pre>
</div>
<div className="question-type">
<h4> 9. inserer une image </h4>
<p>Pour insérer une image, vous devez utiliser la syntaxe suivante:</p>
<pre>
<code className="question-code-block">
{'<img '}
<span className="code-comment">{`un_URL_d_image`}</span>
{' >'}
</code>
</pre>
<p style={{ color: 'red' }}>
Attention nous ne supportons pas encore les images en tant que réponses à une
question
</p>
</div>
<div className="question-type">
<h4> 10. Informations supplémentaires </h4>
<p>
GIFT supporte d'autres formats de questions que nous ne gérons pas sur cette
application.
</p>
<p>Vous pouvez retrouver la Documentation de GIFT (en anglais):</p>
<a href="https://ethan-ou.github.io/vscode-gift-docs/docs/questions">
Documentation de GIFT
</a>
</div>
</div>
);
};
export default GiftCheatSheet;

View file

@ -0,0 +1,37 @@
.gift-cheat-sheet {
width: 30vw;
height: 100%;
}
.subtitle {
color: #3a3a3a;
margin-bottom: 2vh;
}
.question-type {
margin-bottom: 20;
}
.question-code-block,
.code-comment {
white-space: pre-line;
}
code {
font-family: 'Courier New', Courier, monospace;
padding: 2px 4px;
border-radius: 4px;
}
pre {
background-color: #ffffffbd;
padding: 10px;
border: 1px solid #000;
border-radius: 4px;
overflow-x: auto;
}
.code-comment {
color: green;
}
.question-type h4 {
margin-top: 20px;
}

View file

@ -0,0 +1,86 @@
// GIFTTemplatePreview.tsx
import React, { useEffect, useState } from 'react';
import Template, { ErrorTemplate } from './templates';
import { parse } from 'gift-pegjs';
import './styles.css';
interface GIFTTemplatePreviewProps {
questions: string[];
hideAnswers?: boolean;
}
const GIFTTemplatePreview: React.FC<GIFTTemplatePreviewProps> = ({
questions,
hideAnswers = false
}) => {
const [error, setError] = useState('');
const [isPreviewReady, setIsPreviewReady] = useState(false);
const [items, setItems] = useState('');
useEffect(() => {
try {
let previewHTML = '';
questions.forEach((item) => {
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 {
const parsedItem = parse(item);
previewHTML += Template(parsedItem[0], {
preview: true,
theme: 'light'
});
} catch (error) {
if (error instanceof Error) {
previewHTML += ErrorTemplate(item + '\n' + error.message);
} else {
previewHTML += ErrorTemplate(item + '\n' + 'Erreur inconnue');
}
}
previewHTML += '';
});
if (hideAnswers) {
const svgRegex = /<svg[^>]*>([\s\S]*?)<\/svg>/gi;
previewHTML = previewHTML.replace(svgRegex, '');
const placeholderRegex = /(placeholder=")[^"]*(")/gi;
previewHTML = previewHTML.replace(placeholderRegex, '$1$2');
}
setItems(previewHTML);
setIsPreviewReady(true);
} catch (error: unknown) {
if (error instanceof Error) {
setError(error.message);
} else {
setError('Une erreur est survenue durant le chargement de la prévisualisation.');
}
}
}, [questions]);
const PreviewComponent = () => (
<React.Fragment>
{error ? (
<div className="error">{error}</div>
) : isPreviewReady ? (
<div data-testid="preview-container">
<div dangerouslySetInnerHTML={{ __html: items }}></div>
</div>
) : (
<div className="loading">Chargement de la prévisualisation...</div>
)}
</React.Fragment>
);
return <PreviewComponent />;
};
export default GIFTTemplatePreview;

View file

@ -0,0 +1,52 @@
export const colors = {
red100: 'hsl(0, 100%, 97%)',
red300: 'hsl(0, 53%, 90%)',
red700: 'hsl(0, 45%, 25%)',
redGray800: 'hsl(0, 23%, 18%)',
beige100: 'hsl(43, 100%, 94%)',
beige300: 'hsl(36, 84%, 93%)',
beige400: 'hsl(36, 39%, 75%)',
beige500: 'hsl(36, 50%, 50%)',
beige600: 'hsl(35, 51%, 33%)',
beige900: 'hsl(43, 95%, 9%)',
beigeGray800: 'hsl(43, 23%, 33%)',
green100: 'hsl(134, 68%, 95%)',
green300: 'hsl(134, 31%, 82%)',
green400: 'hsl(134, 31%, 75%)',
green500: 'hsl(134, 31%, 66%)',
green600: 'hsl(134, 31%, 44%)',
green700: 'hsl(134, 31%, 32%)',
greenGray500: 'hsl(134, 18%, 50%)',
greenGray600: 'hsl(134, 21%, 44%)',
greenGray700: 'hsl(134, 23%, 33%)',
teal400: 'hsl(180, 35%, 89%)',
teal500: 'hsl(180, 35%, 84%)',
teal600: 'hsl(180, 24%, 60%)',
teal700: 'hsl(180, 15%, 41%)',
cyan100: 'hsl(194, 55%, 98%)',
cyan200: 'hsl(194, 60%, 96%)',
cyan300: 'hsl(194, 65%, 92%)',
navy600: 'hsl(218, 17%, 35%)',
gray100: 'hsl(0, 0%, 95%)',
gray200: 'hsl(0, 0%, 88%)',
gray300: 'hsl(0, 0%, 81%)',
gray400: 'hsl(0, 0%, 74%)',
gray500: 'hsl(0, 0%, 67%)',
gray600: 'hsl(0, 0%, 60%)',
gray700: 'hsl(0, 0%, 53%)',
gray800: 'hsl(0, 0%, 46%)',
gray900: 'hsl(0, 0%, 39%)',
black100: 'hsl(0, 0%, 32%)',
black200: 'hsl(0, 0%, 28%)',
black300: 'hsl(0, 0%, 24%)',
black400: 'hsl(0, 0%, 20%)',
black500: 'hsl(0, 0%, 16%)',
black600: 'hsl(0, 0%, 12%)',
black700: 'hsl(0, 0%, 8%)',
black800: 'hsl(0, 0%, 4%)',
black900: 'hsl(0, 0%, 0%)',
blue: '#5271FF',
success: 'hsl(120, 39%, 54%)',
danger: 'hsl(2, 64%, 58%)',
white: 'hsl(0, 0%, 100%)'
};

View file

@ -0,0 +1,3 @@
export { colors } from './colors';
export { theme } from './theme';
export * from './styles';

View file

@ -0,0 +1,58 @@
import { ThemeType } from '../templates/types';
import { theme } from './theme';
export const ParagraphStyle = (t: ThemeType) => `
color: ${theme(t, 'black900', 'gray200')};
`;
export const TextAreaStyle = (t: ThemeType) => `
width: 100%;
height: 7rem;
line-height: 1.5;
color: ${theme(t, 'black500', 'gray200')};
background-color: ${theme(t, 'white', 'black300')};
border: ${t === 'light' ? 1 : 1.5}px solid ${theme(t, 'gray300', 'gray900')};
border-radius: 0.25rem;
box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
padding: 0.5rem;
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
`;
export const SelectStyle = (t: ThemeType) => `
display: inline-block;
box-sizing: border-box;
align-items: center;
white-space: pre;
cursor: default;
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
text-transform: none;
min-width: 8rem;
line-height: 1.5;
color: ${theme(t, 'black500', 'gray200')};
background-color: ${theme(t, 'white', 'black300')};
border: ${t === 'light' ? 1 : 1.5}px solid ${theme(t, 'gray300', 'gray900')};
border-radius: 0.25rem;
box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
`;
export const InputStyle = (t: ThemeType) => `
display: inline-block;
padding: 0.375rem 0.75rem;
line-height: 1.5;
color: ${theme(t, 'black500', 'gray200')};
background-color: ${theme(t, 'white', 'black300')};
border: ${t === 'light' ? 1 : 2}px solid ${theme(t, 'gray300', 'gray900')};
border-radius: 0.25rem;
box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
margin: 0;
font-family: inherit;
font-size: inherit;
line-height: inherit;
width: 90%;
`;

View file

@ -0,0 +1,11 @@
import { colors } from './colors';
type Color = keyof typeof colors;
export const theme = (theme: 'light' | 'dark', light: Color, dark: Color) => {
if (theme === 'light') {
return colors[light];
} else {
return colors[dark];
}
};

View file

@ -0,0 +1,263 @@
import Template, { ErrorTemplate } from './templates';
import { GIFTQuestion } from './templates/types';
import './styles.css';
const multiple: GIFTQuestion[] = [
{
type: 'MC',
title: null,
stem: { format: 'markdown', text: "Who's buried in Grant's \r\n tomb?" },
hasEmbeddedAnswers: false,
globalFeedback: {
format: 'moodle',
text: 'Not sure? There are many answers for this question so do not fret. Not sure? There are many answers for this question so do not fret.'
},
choices: [
{
isCorrect: false,
weight: -50,
text: { format: 'moodle', text: 'Grant' },
feedback: null
},
{
isCorrect: true,
weight: 50,
text: { format: 'moodle', text: 'Jefferson' },
feedback: null
},
{
isCorrect: true,
weight: 50,
text: { format: 'moodle', text: 'no one' },
feedback: null
}
]
},
{
type: 'MC',
title: null,
stem: { format: 'moodle', text: "Grant is _____ in Grant's tomb." },
hasEmbeddedAnswers: true,
globalFeedback: null,
choices: [
{
isCorrect: true,
weight: null,
text: { format: 'moodle', text: 'buried' },
feedback: null
},
{
isCorrect: true,
weight: null,
text: { format: 'moodle', text: 'entombed' },
feedback: null
},
{
isCorrect: false,
weight: null,
text: { format: 'moodle', text: 'living' },
feedback: null
}
]
},
{
type: 'TF',
title: null,
stem: { format: 'moodle', text: "Grant is buried in Grant's tomb." },
hasEmbeddedAnswers: false,
globalFeedback: null,
isTrue: false,
incorrectFeedback: null,
correctFeedback: null
},
{
type: 'Short',
title: null,
stem: { format: 'moodle', text: "Who's buried in Grant's tomb?" },
hasEmbeddedAnswers: false,
globalFeedback: null,
choices: [
{
isCorrect: true,
weight: null,
text: { format: 'moodle', text: 'no one " has got me' },
feedback: null
},
{
isCorrect: true,
weight: null,
text: { format: 'moodle', text: 'nobody' },
feedback: null
}
]
},
{
type: 'Numerical',
title: null,
stem: { format: 'moodle', text: 'When was Ulysses S. Grant born?' },
hasEmbeddedAnswers: false,
globalFeedback: null,
choices: {
type: 'range',
number: 1822,
range: 5
}
},
{
type: 'Matching',
title: null,
stem: {
format: 'moodle',
text: 'Match the following countries with their corresponding capitals.'
},
hasEmbeddedAnswers: false,
globalFeedback: null,
matchPairs: [
{
subquestion: { format: 'moodle', text: 'Canada' },
subanswer: 'Ottawa'
},
{
subquestion: { format: 'moodle', text: 'Italy' },
subanswer: 'Rome'
},
{
subquestion: { format: 'moodle', text: 'Japan' },
subanswer: 'Tokyo'
}
]
},
{
type: 'MC',
title: "Grant's Tomb",
stem: { format: 'moodle', text: "Grant is _____ in Grant's tomb." },
hasEmbeddedAnswers: true,
globalFeedback: null,
choices: [
{
isCorrect: false,
weight: null,
text: { format: 'moodle', text: 'buried' },
feedback: { format: 'moodle', text: 'No one is buried there.' }
},
{
isCorrect: true,
weight: null,
text: { format: 'moodle', text: 'entombed' },
feedback: { format: 'moodle', text: 'Right answer!' }
},
{
isCorrect: false,
weight: null,
text: { format: 'moodle', text: 'living' },
feedback: { format: 'moodle', text: 'We hope not!' }
}
]
},
{
type: 'MC',
title: null,
stem: { format: 'moodle', text: 'Difficult multiple choice question.' },
hasEmbeddedAnswers: false,
globalFeedback: null,
choices: [
{
isCorrect: false,
weight: null,
text: { format: 'moodle', text: 'wrong answer' },
feedback: { format: 'moodle', text: 'comment on wrong answer' }
},
{
isCorrect: false,
weight: 50,
text: { format: 'moodle', text: 'half credit answer' },
feedback: { format: 'moodle', text: 'comment on answer' }
},
{
isCorrect: true,
weight: null,
text: { format: 'moodle', text: 'full credit answer' },
feedback: { format: 'moodle', text: 'well done!' }
}
]
},
{
type: 'Short',
title: "Jesus' hometown (Short answer ex.)",
stem: { format: 'moodle', text: 'Jesus Christ was from _____ .' },
hasEmbeddedAnswers: true,
globalFeedback: null,
choices: [
{
isCorrect: true,
weight: null,
text: { format: 'moodle', text: 'Nazareth' },
feedback: { format: 'moodle', text: "Yes! That's right!" }
},
{
isCorrect: true,
weight: 75,
text: { format: 'moodle', text: 'Nazereth' },
feedback: { format: 'moodle', text: 'Right, but misspelled.' }
},
{
isCorrect: true,
weight: 25,
text: { format: 'moodle', text: 'Bethlehem' },
feedback: {
format: 'moodle',
text: 'He was born here, but not raised here.'
}
}
]
},
{
type: 'Numerical',
title: 'Numerical example',
stem: { format: 'moodle', text: 'When was Ulysses S. Grant born?' },
hasEmbeddedAnswers: false,
globalFeedback: null,
choices: [
{
isCorrect: true,
weight: null,
text: {
type: 'range',
number: 1822,
range: 0
},
feedback: { format: 'moodle', text: 'Correct! 100% credit' }
},
{
isCorrect: true,
weight: 50,
text: {
type: 'range',
number: 1822,
range: 2
},
feedback: {
format: 'moodle',
text: 'He was born in 1822. You get 50% credit for being close.'
}
}
]
},
{
type: 'Essay',
title: 'Essay Example',
stem: { format: 'moodle', text: 'This is an essay.' },
hasEmbeddedAnswers: false,
globalFeedback: null
}
];
const items = multiple.map((item) => Template(item, { theme: 'dark' })).join('');
const errorItemDark = ErrorTemplate('Hello');
const lightItems = multiple.map((item) => Template(item, { theme: 'light' })).join('');
const errorItem = ErrorTemplate('Hello');
const app = document.getElementById('app');
if (app) app.innerHTML = items + errorItemDark + lightItems + errorItem;

View file

@ -0,0 +1,86 @@
/* :root {
--correct: hsla(134, 31%, 32%, 1);
--correct-light: hsla(134, 68%, 95%, 1);
--light-gray: rgb(202, 202, 202);
--moodle-blue-lighter: hsla(194, 55%, 98%, 1);
--moodle-blue-light: hsla(194, 60%, 96%, 1);
--moodle-blue: #def2f8;
--moodle-blue-dark: #c7e4e4;
--moodle-blue-darker: #81b1b1;
--moodle-blue-darkest: rgb(87, 119, 119);
--moodle-alt-light: #fff8e2;
--moodle-alt: #fcefdc;
--moodle-alt-dark: #b8945e;
--moodle-alt-darker: #7d5a29;
--moodle-alt-darkest: #2b2101;
--moodle-error-light: #fff0f0;
--moodle-error: #f3d8d8;
--default-box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
--form-input-color: rgb(74, 85, 104);
} */
/* body {
background-color: #121212;
} */
/* :root {
--correct: hsla(134, 31%, 32%, 1);
--correct-light: hsla(134, 68%, 95%, 1);
--light-gray: rgb(202, 202, 202);
--moodle-blue-lighter: hsla(194, 55%, 98%, 1);
--moodle-blue-light: hsla(194, 60%, 96%, 1);
--moodle-blue: #def2f8;
--moodle-blue-dark: #c7e4e4;
--moodle-blue-darker: #81b1b1;
--moodle-blue-darkest: rgb(87, 119, 119);
--moodle-alt-light: #fff8e2;
--moodle-alt: #fcefdc;
--moodle-alt-dark: #b8945e;
--moodle-alt-darker: #7d5a29;
--moodle-alt-darkest: #2b2101;
--moodle-error-light: #fff0f0;
--moodle-error: #f3d8d8;
--default-box-shadow: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
--form-input-color: rgb(74, 85, 104);
} */
/* * {
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto,
Helvetica Neue, Arial, sans-serif, Apple Color Emoji, Segoe UI Emoji,
Segoe UI Symbol;
line-height: 1.5rem;
} */
/*
.gift-textarea::-webkit-input-placeholder {
color: #999;
}
.gift-textarea::-moz-placeholder {
color: #999;
}
.gift-textarea:-ms-input-placeholder {
color: #999;
}
.gift-textarea:-moz-placeholder {
color: #999;
} */
.present-question-title {
margin-top: 8vh;
margin-bottom: 2vh;
font-size: 2rem;
font-weight: 500;
}
.preview-container {
margin-bottom: 2vh;
width: 60vw;
height: 100%;
}
.multiple-choice-answers-container {
margin: 1% 0% 1% 0%;
}
.multiple-choice-answers-container > * {
display: inline;
vertical-align: middle;
}

View file

@ -0,0 +1,36 @@
import { TemplateOptions } from './types';
import { theme } from '../constants';
import { state } from '.';
interface AnswerIconOptions extends TemplateOptions {
correct: boolean;
}
export default function AnswerIcon({ correct }: AnswerIconOptions): string {
const Icon = `
vertical-align: text-bottom;
display: inline-block;
margin-left: 0.1rem;
margin-right: 0.2rem;
`;
const Correct = `
width: 1em;
color: ${theme(state.theme, 'success', 'success')};
`;
const Incorrect = `
width: 0.75em;
color: ${theme(state.theme, 'danger', 'danger')};
`;
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>`;
};
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 correct ? CorrectIcon() : IncorrectIcon();
}

View file

@ -0,0 +1,14 @@
import { TemplateOptions, Category as CategoryType } from './types';
import QuestionContainer from './QuestionContainer';
import Title from './Title';
type CategoryOptions = TemplateOptions & CategoryType;
export default function Category({ title }: CategoryOptions): string {
return `${QuestionContainer({
children: Title({
type: 'Catégorie',
title: title
})
})}`;
}

View file

@ -0,0 +1,22 @@
import { TemplateOptions, Description as DescriptionType } from './types';
import QuestionContainer from './QuestionContainer';
import Title from './Title';
import TextType from './TextType';
import { ParagraphStyle } from '../constants';
import { state } from '.';
type DescriptionOptions = TemplateOptions & DescriptionType;
export default function Description({ title, stem }: DescriptionOptions): string {
return `${QuestionContainer({
children: [
Title({
type: 'Description',
title: title
}),
`<p style="${ParagraphStyle(state.theme)}">${TextType({
text: stem
})}</p>`
]
})}`;
}

View file

@ -0,0 +1,59 @@
import { theme, ParagraphStyle } from '../constants';
import { state } from '.';
export default function (text: string): string {
const Container = `
flex-wrap: wrap;
position: relative;
padding: 1rem 1rem;
margin-bottom: 0.5rem;
background-color: ${theme(state.theme, 'red100', 'redGray800')};
border: solid ${theme(state.theme, 'red300', 'red700')} 2px;
border-radius: 6px;
box-shadow: 0px 1px 3px ${theme(state.theme, 'gray400', 'black900')};
`;
const document = removeBackslash(lineRegex(documentRegex(text))).split(/\r?\n/);
return document[0] !== ``
? `<section style="${Container}">${document
.map((i) => `<p style="${ParagraphStyle(state.theme)}">${i}</p>`)
.join('')}</section>`
: ``;
}
function documentRegex(text: string): string {
const newText = text
.split(/\r?\n/)
.map((comment) => comment.replace(/(^[ \\t]+)?(^)((\/\/))(.*)/gm, ''))
.join('');
const newLineAnswer = /([^\\]|[^\S\r\n][^=])(=|~)/g;
const correctAnswer = /([^\\]|^{)(([^\\]|^|\\s*)=(.*)(?=[=~}]|\\n))/g;
const incorrectAnswer = /([^\\]|^{)(([^\\]|^|\\s*)~(.*)(?=[=~}]|\\n))/g;
return newText
.replace(newLineAnswer, `\n$2`)
.replace(correctAnswer, `$1<li>$4</li>`)
.replace(incorrectAnswer, `$1<li>$4</li>`);
}
function lineRegex(text: string): string {
return text
.split(/\r?\n/)
.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((closeBracket) => closeBracket.replace(/([^\\]|^)}/g, `$1<br>`))
.join('');
}
function removeBackslash(text: string): string {
return text
.split(/\r?\n/)
.map((colon) => colon.replace(/[\\]:/g, ':'))
.map((openBracket) => openBracket.replace(/[\\]{/g, '{'))
.map((closeBracket) => closeBracket.replace(/[\\]}/g, '}'))
.join('');
}

View file

@ -0,0 +1,27 @@
import { TemplateOptions, Essay as EssayType } from './types';
import QuestionContainer from './QuestionContainer';
import Title from './Title';
import TextType from './TextType';
import GlobalFeedback from './GlobalFeedback';
import { ParagraphStyle, TextAreaStyle } from '../constants';
import { state } from '.';
type EssayOptions = TemplateOptions & EssayType;
export default function Essay({ title, stem, globalFeedback }: EssayOptions): string {
return `${QuestionContainer({
children: [
Title({
type: 'Développement',
title: title
}),
`<p style="${ParagraphStyle(state.theme)}">${TextType({
text: stem
})}</p>`,
`<textarea class="gift-textarea" style="${TextAreaStyle(
state.theme
)}" placeholder="Entrez votre réponse ici..."></textarea>`,
GlobalFeedback({ feedback: globalFeedback })
]
})}`;
}

View file

@ -0,0 +1,27 @@
import { TemplateOptions, Question } from './types';
import TextType from './TextType';
import { state } from '.';
import { theme } from '../constants';
interface GlobalFeedbackOptions extends TemplateOptions {
feedback: Question['globalFeedback'];
}
export default function GlobalFeedback({ feedback }: GlobalFeedbackOptions): string {
const Container = `
position: relative;
margin-top: 1rem;
padding: 0 1rem;
background-color: ${theme(state.theme, 'beige100', 'black400')};
color: ${theme(state.theme, 'beige900', 'gray200')};
border: ${theme(state.theme, 'beige300', 'black300')} ${state.theme === 'light' ? 1 : 2}px solid;
border-radius: 6px;
box-shadow: 0px 2px 5px ${theme(state.theme, 'gray400', 'black800')};
`;
return feedback !== null
? `<div style="${Container}">
<p>${TextType({ text: feedback })}</p>
</div>`
: ``;
}

View file

@ -0,0 +1,87 @@
import { TemplateOptions, Matching as MatchingType } from './types';
import QuestionContainer from './QuestionContainer';
import Title from './Title';
import TextType from './TextType';
import GlobalFeedback from './GlobalFeedback';
import { ParagraphStyle, SelectStyle } from '../constants';
import { state } from '.';
type MatchingOptions = TemplateOptions & MatchingType;
interface MatchAnswerOptions extends TemplateOptions {
choices: MatchingType['matchPairs'];
}
export default function Matching({
title,
stem,
matchPairs,
globalFeedback
}: MatchingOptions): string {
return `${QuestionContainer({
children: [
Title({
type: 'Appariement',
title: title
}),
`<p style="${ParagraphStyle(state.theme)}">${TextType({
text: stem
})}</p>`,
MatchAnswers({ choices: matchPairs }),
GlobalFeedback({ feedback: globalFeedback })
]
})}`;
}
function MatchAnswers({ choices }: MatchAnswerOptions): string {
const Layout = `
display: grid;
grid-template-columns: fit-content(50%) fit-content(50%);
grid-gap: 0.25rem;
align-items: center;
`;
const Dropdown = `
padding: 0.375rem 1.75rem 0.375rem 0.75rem;
background-image: url(data:image/svg+xml;charset=UTF-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2212%22%20height%3D%2212%22%20viewBox%3D%220%200%2012%2012%22%3E%3Ctitle%3Edown-arrow%3C%2Ftitle%3E%3Cg%20fill%3D%22%23000000%22%3E%3Cpath%20d%3D%22M10.293%2C3.293%2C6%2C7.586%2C1.707%2C3.293A1%2C1%2C0%2C0%2C0%2C.293%2C4.707l5%2C5a1%2C1%2C0%2C0%2C0%2C1.414%2C0l5-5a1%2C1%2C0%2C1%2C0-1.414-1.414Z%22%20fill%3D%22%23${
state.theme === 'light' ? '000' : 'ccc'
}%22%3E%3C%2Fpath%3E%3C%2Fg%3E%3C%2Fsvg%3E);
background-size: 0.6em;
background-position: calc(100% - 0.5em) center;
background-repeat: no-repeat;
border-radius: 0.25rem;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
width: auto;
vertical-align: baseline;
`;
const OptionTable = `
padding-right: 1rem;
`;
const uniqueMatchOptions = Array.from(new Set(choices.map(({ subanswer }) => subanswer)));
const result = choices
.map(({ subquestion }) => {
return `
<div style="${OptionTable} ${ParagraphStyle(state.theme)}">
${TextType({ text: subquestion })}
</div>
<div>
<select class="gift-select" style="${SelectStyle(state.theme)} ${Dropdown}">
<option value="" disabled selected hidden>Choisir...</option>
${uniqueMatchOptions.map((subanswer) => `<option>${subanswer}</option>`).join('')}
</select>
</div>
`;
})
.join('');
return `
<div style="${Layout}">
${result}
</div>
`;
}

View file

@ -0,0 +1,31 @@
import { TemplateOptions, MultipleChoice as MultipleChoiceType } from './types';
import QuestionContainer from './QuestionContainer';
import GlobalFeedback from './GlobalFeedback';
import Title from './Title';
import TextType from './TextType';
import MultipleChoiceAnswers from './MultipleChoiceAnswers';
import { ParagraphStyle } from '../constants';
import { state } from '.';
type MultipleChoiceOptions = TemplateOptions & MultipleChoiceType;
export default function MultipleChoice({
title,
stem,
choices,
globalFeedback
}: MultipleChoiceOptions): string {
return `${QuestionContainer({
children: [
Title({
type: 'Choix multiple',
title: title
}),
`<p style="${ParagraphStyle(state.theme)}">${TextType({
text: stem
})}</p>`,
MultipleChoiceAnswers({ choices: choices }),
GlobalFeedback({ feedback: globalFeedback })
]
})}`;
}

View file

@ -0,0 +1,89 @@
import { nanoid } from 'nanoid';
import { TemplateOptions, TextFormat, Choice, MultipleChoice as MultipleChoiceType } from './types';
import TextType from './TextType';
import AnswerIcon from './AnswerIcon';
import { state } from '.';
import { ParagraphStyle, theme } from '../constants';
type MultipleChoiceAnswerOptions = TemplateOptions & Pick<MultipleChoiceType, 'choices'>;
type AnswerFeedbackOptions = TemplateOptions & Pick<Choice, 'feedback'>;
interface AnswerWeightOptions extends TemplateOptions {
weight: Choice['weight'];
correct: Choice['isCorrect'];
}
export default function MultipleChoiceAnswers({ choices }: MultipleChoiceAnswerOptions) {
const id = `id${nanoid(8)}`;
const isMultipleAnswer = choices.filter(({ isCorrect }) => isCorrect === true).length === 0;
const prompt = `<span style="${ParagraphStyle(state.theme)}">Choisir une réponse${
isMultipleAnswer ? ` ou plusieurs` : ``
}:</span>`;
const result = choices
.map(({ weight, isCorrect, text, feedback }) => {
const CustomLabel = `
display: inline-block;
padding: 0.2em 0 0.2em 0;
`;
const inputId = `id${nanoid(6)}`;
const isPositiveWeight = weight !== null && weight > 0;
const isCorrectOption = isMultipleAnswer ? isPositiveWeight : isCorrect;
return `
<div class='multiple-choice-answers-container'>
<input class="gift-input" type="${
isMultipleAnswer ? 'checkbox' : 'radio'
}" id="${inputId}" name="${id}">
${AnswerWeight({ correct: isCorrectOption, weight: weight })}
<label style="${CustomLabel} ${ParagraphStyle(state.theme)}" for="${inputId}">
${TextType({ text: text as TextFormat })}
</label>
${AnswerIcon({ correct: isCorrectOption })}
${AnswerFeedback({ feedback: feedback })}
</input>
</div>
`;
})
.join('');
return `${prompt}${result}`;
}
function AnswerWeight({ weight, correct }: AnswerWeightOptions): string {
const Container = `
box-shadow: 0px 1px 1px ${theme(state.theme, 'gray400', 'black900')};
border-radius: 3px;
padding-left: 0.2rem;
padding-right: 0.2rem;
padding-top: 0.05rem;
padding-bottom: 0.05rem;
`;
const CorrectWeight = `
color: ${theme(state.theme, 'green700', 'green100')};
background-color: ${theme(state.theme, 'green100', 'greenGray700')};
`;
const IncorrectWeight = `
color: ${theme(state.theme, 'beige600', 'beige100')};
background-color: ${theme(state.theme, 'beige300', 'beigeGray800')};
`;
return weight
? `<span style="${Container} ${
correct ? `${CorrectWeight}` : `${IncorrectWeight}`
}">${weight}%</span>`
: ``;
}
function AnswerFeedback({ feedback }: AnswerFeedbackOptions): string {
const Container = `
color: ${theme(state.theme, 'teal700', 'gray700')};
`;
return feedback ? `<span style="${Container}">${TextType({ text: feedback })}</span>` : ``;
}

View file

@ -0,0 +1,60 @@
import { TemplateOptions, Numerical as NumericalType, NumericalFormat } from './types';
import QuestionContainer from './QuestionContainer';
import Title from './Title';
import TextType from './TextType';
import GlobalFeedback from './GlobalFeedback';
import { ParagraphStyle, InputStyle } from '../constants';
import { state } from '.';
type NumericalOptions = TemplateOptions & NumericalType;
type NumericalAnswerOptions = TemplateOptions & Pick<NumericalType, 'choices'>;
export default function Numerical({
title,
stem,
choices,
globalFeedback
}: NumericalOptions): string {
return `${QuestionContainer({
children: [
Title({
type: 'Numérique',
title: title
}),
`<p style="${ParagraphStyle(state.theme)}">${TextType({
text: stem
})}</p>`,
NumericalAnswers({ choices: choices }),
GlobalFeedback({ feedback: globalFeedback })
]
})}`;
}
function NumericalAnswers({ choices }: NumericalAnswerOptions): string {
const placeholder = Array.isArray(choices)
? choices.map(({ text }) => Answer(text)).join(', ')
: Answer(choices);
return `
<div>
<span style="${ParagraphStyle(
state.theme
)}">Réponse: </span><input class="gift-input" type="text" style="${InputStyle(
state.theme
)}" placeholder="${placeholder}">
</div>
`;
}
function Answer({ type, number, range, numberLow, numberHigh }: NumericalFormat): string {
switch (type) {
case 'simple':
return `${number}`;
case 'range':
return `${number} ± ${range}`;
case 'high-low':
return `${numberLow} - ${numberHigh}`;
default:
return ``;
}
}

View file

@ -0,0 +1,20 @@
import { TemplateOptions } from './types';
import { state } from './index';
import { theme } from '../constants';
export default function QuestionContainer({ children }: TemplateOptions): string {
const Container = `
flex-wrap: wrap;
position: relative;
padding: 1rem 1rem;
margin-bottom: 0.5rem;
background-color: ${theme(state.theme, 'white', 'black600')};
border: solid ${theme(state.theme, 'white', 'black500')} 2px;
border-radius: 6px;
box-shadow: 0px 1px 3px ${theme(state.theme, 'gray400', 'black900')};
`;
return `<section style="${Container}">${
Array.isArray(children) ? children.join('') : children
}</section>`;
}

View file

@ -0,0 +1,46 @@
import { TemplateOptions, ShortAnswer as ShortAnswerType, TextFormat } from './types';
import QuestionContainer from './QuestionContainer';
import Title from './Title';
import TextType from './TextType';
import GlobalFeedback from './GlobalFeedback';
import { ParagraphStyle, InputStyle } from '../constants';
import { state } from './index';
type ShortAnswerOptions = TemplateOptions & ShortAnswerType;
type AnswerOptions = TemplateOptions & Pick<ShortAnswerType, 'choices'>;
export default function ShortAnswer({
title,
stem,
choices,
globalFeedback
}: ShortAnswerOptions): string {
return `${QuestionContainer({
children: [
Title({
type: 'Réponse courte',
title: title
}),
`<p style="${ParagraphStyle(state.theme)}">${TextType({
text: stem
})}</p>`,
Answers({ choices: choices }),
GlobalFeedback({ feedback: globalFeedback })
]
})}`;
}
function Answers({ choices }: AnswerOptions): string {
const placeholder = choices
.map(({ text }) => TextType({ text: text as TextFormat }))
.join(', ');
return `
<div>
<span style="${ParagraphStyle(
state.theme
)}">Réponse: </span><input class="gift-input" type="text" style="${InputStyle(
state.theme
)}" placeholder="${placeholder}">
</div>
`;
}

View file

@ -0,0 +1,46 @@
import { marked } from 'marked';
import katex from 'katex';
import { TemplateOptions, TextFormat } from './types';
interface TextTypeOptions extends TemplateOptions {
text: TextFormat;
}
function formatLatex(text: string): string {
return 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 })
);
}
function escapeHTML(text: string) {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
export default function TextType({ text }: TextTypeOptions): string {
const formatText = formatLatex(escapeHTML(text.text.trim()));
switch (text.format) {
case 'moodle':
case 'plain':
return formatText.replace(/(?:\r\n|\r|\n)/g, '<br>');
case 'html':
return formatText.replace(/(^<p>)(.*?)(<\/p>)$/gm, '$2');
case 'markdown':
return (
marked
.parse(formatText, { breaks: true }) // call marked.parse instead of marked
// Strip outer paragraph tags
.replace(/(^<p>)(.*?)(<\/p>)$/gm, '$2')
);
default:
return ``;
}
}

View file

@ -0,0 +1,56 @@
import { TemplateOptions, Question } from './types';
import { state } from '.';
import { theme } from '../constants';
// Type is string to allow for custom question type text (e,g, "Multiple Choice")
interface TitleOptions extends TemplateOptions {
type: string;
title: Question['title'];
}
export default function Title({ type, title }: TitleOptions): string {
const Container = `
display: flex;
font-weight: bold;
`;
const QuestionTitle = `
color: ${theme(state.theme, 'blue', 'gray200')};
`;
const OptionalTitle = `
color: ${theme(state.theme, 'blue', 'gray900')};
`;
const QuestionTypeContainer = `
margin-left: auto;
padding-left: 0.75rem;
flex: none;
`;
const QuestionType = `
box-shadow: 0px 1px 3px ${theme(state.theme, 'gray400', 'black700')};
padding-left: 0.7rem;
padding-right: 0.7rem;
padding-top: 0.4rem;
padding-bottom: 0.4rem;
border-radius: 4px;
background-color: ${theme(state.theme, 'white', 'black400')};
color: ${theme(state.theme, 'teal700', 'gray300')};
`;
return `
<div style="${Container}">
<span>
${
title !== null
? `<span style="${QuestionTitle}">${title}</span>`
: `<span style="${OptionalTitle}">Titre optionnel...</span>`
}
</span>
<span style="${QuestionTypeContainer} margin-bottom: 10px;">
<span style="${QuestionType}">${type}</span>
</span>
</div>
`;
}

View file

@ -0,0 +1,54 @@
import { TemplateOptions, TextChoice, TrueFalse as TrueFalseType } from './types';
import QuestionContainer from './QuestionContainer';
import TextType from './TextType';
import GlobalFeedback from './GlobalFeedback';
import MultipleChoiceAnswers from './MultipleChoiceAnswers';
import Title from './Title';
import { ParagraphStyle } from '../constants';
import { state } from '.';
type TrueFalseOptions = TemplateOptions & TrueFalseType;
export default function TrueFalse({
title,
isTrue,
stem,
correctFeedback,
incorrectFeedback,
globalFeedback
}: TrueFalseOptions): string {
const choices: TextChoice[] = [
{
text: {
format: 'moodle',
text: 'Vrai'
},
isCorrect: isTrue,
weight: null,
feedback: isTrue ? correctFeedback : incorrectFeedback
},
{
text: {
format: 'moodle',
text: 'Faux'
},
isCorrect: !isTrue,
weight: null,
feedback: !isTrue ? correctFeedback : incorrectFeedback
}
];
return `${QuestionContainer({
children: [
Title({
type: 'Vrai/Faux',
title: title
}),
`<p style="${ParagraphStyle(state.theme)}">${TextType({
text: stem
})}</p>`,
MultipleChoiceAnswers({ choices: choices }),
GlobalFeedback({ feedback: globalFeedback })
]
})}`;
}

View file

@ -0,0 +1,75 @@
import Category from './Category';
import Description from './Description';
import Essay from './Essay';
import Matching from './Matching';
import MultipleChoice from './MultipleChoice';
import Numerical from './Numerical';
import ShortAnswer from './ShortAnswer';
import TrueFalse from './TrueFalse';
import Error from './Error';
import {
GIFTQuestion,
Category as CategoryType,
Description as DescriptionType,
MultipleChoice as MultipleChoiceType,
Numerical as NumericalType,
ShortAnswer as ShortAnswerType,
Essay as EssayType,
TrueFalse as TrueFalseType,
Matching as MatchingType,
DisplayOptions
} from './types';
export const state: DisplayOptions = { preview: true, theme: 'light' };
export default function Template(
{ type, ...keys }: GIFTQuestion,
options?: Partial<DisplayOptions>
): string {
Object.assign(state, options);
switch (type) {
case 'Category':
return Category({ ...(keys as CategoryType) });
case 'Description':
return Description({
...(keys as DescriptionType)
});
case 'MC':
return MultipleChoice({
...(keys as MultipleChoiceType)
});
case 'Numerical':
return Numerical({ ...(keys as NumericalType) });
case 'Short':
return ShortAnswer({
...(keys as ShortAnswerType)
});
case 'Essay':
return Essay({ ...(keys as EssayType) });
case 'TF':
return TrueFalse({ ...(keys as TrueFalseType) });
case 'Matching':
return Matching({ ...(keys as MatchingType) });
default:
return ``;
}
}
export function ErrorTemplate(text: string, options?: Partial<DisplayOptions>): string {
Object.assign(state, options);
return Error(text);
}
export {
Category,
Description,
Essay,
Matching,
MultipleChoice,
Numerical,
ShortAnswer,
TrueFalse,
Error
};

View file

@ -0,0 +1,120 @@
export type Template = (options: TemplateOptions) => string;
export interface TemplateOptions {
children?: Template | string | Array<Template | string>;
options?: DisplayOptions;
}
export type ThemeType = 'light' | 'dark';
export interface DisplayOptions {
theme: ThemeType;
preview: boolean;
}
export type QuestionType =
| 'Description'
| 'Category'
| 'MC'
| 'Numerical'
| 'Short'
| 'Essay'
| 'TF'
| 'Matching';
export type FormatType = 'moodle' | 'html' | 'markdown' | 'plain';
export type NumericalType = 'simple' | 'range' | 'high-low';
export interface TextFormat {
format: FormatType;
text: string;
}
export interface NumericalFormat {
type: NumericalType;
number?: number;
range?: number;
numberHigh?: number;
numberLow?: number;
}
export interface Choice {
isCorrect: boolean;
weight: number | null;
text: TextFormat | NumericalFormat;
feedback: TextFormat | null;
}
export interface TextChoice extends Choice {
text: TextFormat;
}
export interface NumericalChoice extends Choice {
text: NumericalFormat;
}
export interface Question {
type: QuestionType;
title: string | null;
stem: TextFormat;
hasEmbeddedAnswers: boolean;
globalFeedback: TextFormat | null;
}
export interface Description {
type: Extract<QuestionType, 'Description'>;
title: string | null;
stem: TextFormat;
hasEmbeddedAnswers: boolean;
}
export interface Category {
type: Extract<QuestionType, 'Category'>;
title: string;
}
export interface MultipleChoice extends Question {
type: Extract<QuestionType, 'MC'>;
choices: TextChoice[];
}
export interface ShortAnswer extends Question {
type: Extract<QuestionType, 'Short'>;
choices: TextChoice[];
}
export interface Numerical extends Question {
type: Extract<QuestionType, 'Numerical'>;
choices: NumericalChoice[] | NumericalFormat;
}
export interface Essay extends Question {
type: Extract<QuestionType, 'Essay'>;
}
export interface TrueFalse extends Question {
type: Extract<QuestionType, 'TF'>;
isTrue: boolean;
incorrectFeedback: TextFormat | null;
correctFeedback: TextFormat | null;
}
export interface Matching extends Question {
type: Extract<QuestionType, 'Matching'>;
matchPairs: Match[];
}
export interface Match {
subquestion: TextFormat;
subanswer: string;
}
export type GIFTQuestion =
| Description
| Category
| MultipleChoice
| ShortAnswer
| Numerical
| Essay
| TrueFalse
| Matching;

View file

@ -0,0 +1,39 @@
import { useNavigate } from 'react-router-dom';
import * as React from 'react';
import './header.css';
import { Button } from '@mui/material';
interface HeaderProps {
isLoggedIn: () => boolean;
handleLogout: () => void;
}
const Header: React.FC<HeaderProps> = ({ isLoggedIn, handleLogout }) => {
const navigate = useNavigate();
return (
<div className="header">
<img
className="logo"
src="/logo.png"
alt="Logo"
onClick={() => navigate('/')}
/>
{isLoggedIn() && (
<Button
variant="outlined"
color="primary"
onClick={() => {
handleLogout();
navigate('/');
}}
>
Logout
</Button>
)}
</div>
);
};
export default Header;

View file

@ -0,0 +1,14 @@
.header {
flex-shrink: 0;
padding: 15px;
overflow: hidden;
display: flex;
justify-content: space-between;
align-items: center;
}
.header img {
cursor: pointer;
}

View file

@ -0,0 +1,210 @@
import React, { useState, DragEvent, useRef, useEffect } from 'react';
import './importModal.css';
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
IconButton
} from '@mui/material';
import { Clear, Download } from '@mui/icons-material';
import ApiService from '../../services/ApiService';
type DroppedFile = {
id: number;
name: string;
icon: string;
file: File;
};
interface Props {
handleOnClose: () => void;
handleOnImport: () => void;
open: boolean;
selectedFolder: string;
}
const DragAndDrop: React.FC<Props> = ({ handleOnClose, handleOnImport, open, selectedFolder }) => {
const [droppedFiles, setDroppedFiles] = useState<DroppedFile[]>([]);
const fileInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
return () => {
setDroppedFiles([]);
};
}, []);
const handleDragEnter = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const handleDragOver = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
};
const handleDrop = (e: DragEvent<HTMLDivElement>) => {
e.preventDefault();
const files = e.dataTransfer.files;
handleFiles(files);
};
const handleFiles = (files: FileList) => {
const newDroppedFiles = Array.from(files)
.filter((file) => file.name.endsWith('.txt'))
.map((file, index) => ({
id: index,
name: file.name,
icon: '📄',
file
}));
setDroppedFiles((prevFiles) => [...prevFiles, ...newDroppedFiles]);
};
const handleOnSave = async () => {
const storedQuizzes = JSON.parse(localStorage.getItem('quizzes') || '[]');
const quizzesToImportPromises = droppedFiles.map((droppedFile) => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = async (event) => {
if (event.target && event.target.result) {
const fileContent = event.target.result as string;
//console.log(fileContent);
if (fileContent.trim() === '') {
resolve(null);
}
const questions = fileContent.split(/}/)
.map(question => {
// Remove trailing and leading spaces
return question.trim()+"}";
})
.filter(question => question.trim() !== '').slice(0, -1); // Filter out lines with only whitespace characters
try {
// const folders = await ApiService.getUserFolders();
// Assuming you want to use the first folder
// const selectedFolder = folders.length > 0 ? folders[0]._id : null;
await ApiService.createQuiz(droppedFile.name.slice(0, -4) || 'Untitled quiz', questions, selectedFolder);
resolve('success');
} catch (error) {
console.error('Error saving quiz:', error);
}
}
};
reader.readAsText(droppedFile.file);
});
});
Promise.all(quizzesToImportPromises).then((quizzesToImport) => {
const verifiedQuizzesToImport = quizzesToImport.filter((quiz) => {
return quiz !== null;
});
const updatedQuizzes = [...storedQuizzes, ...verifiedQuizzesToImport];
localStorage.setItem('quizzes', JSON.stringify(updatedQuizzes));
setDroppedFiles([]);
handleOnImport();
handleOnClose();
window.location.reload();
});
};
const handleRemoveFile = (id: number) => {
setDroppedFiles((prevFiles) => prevFiles.filter((file) => file.id !== id));
};
const handleFileInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files) {
handleFiles(files);
}
};
const handleBrowseButtonClick = () => {
if (fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleOnCancel = () => {
setDroppedFiles([]);
handleOnClose();
};
return (
<>
<Dialog open={open} onClose={handleOnCancel} fullWidth>
<DialogTitle sx={{ fontWeight: 'bold', fontSize: 24 }}>
{'Importation de quiz'}
</DialogTitle>
<DialogContent
className="import-container"
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
onDrop={handleDrop}
onClick={handleBrowseButtonClick}
>
<div className="mb-1">
<DialogContentText sx={{ textAlign: 'center' }}>
Déposer des fichiers ici ou
<br />
cliquez pour ouvrir l'explorateur des fichiers
</DialogContentText>
</div>
<Download color="primary" />
</DialogContent>
<DialogContent>
{droppedFiles.map((file) => (
<div key={file.id + file.name} className="file-container">
<span>{file.icon}</span>
<span>{file.name}</span>
<IconButton
sx={{ padding: 0 }}
onClick={() => handleRemoveFile(file.id)}
>
<Clear />
</IconButton>
</div>
))}
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={handleOnCancel}>
Annuler
</Button>
<Button variant="contained" onClick={handleOnSave}>
Importer
</Button>
</DialogActions>
</Dialog>
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleFileInputChange}
multiple
/>
</>
);
};
export default DragAndDrop;

View file

@ -0,0 +1,20 @@
.import-container {
border-style: dashed;
border-width: thin;
border-color: rgba(128, 128, 128, 0.5);
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
height: 20vh;
cursor: pointer;
box-sizing: border-box;
margin: 0 20px 0 20px;
}
.file-container {
gap: 10px;
display: flex;
align-items: center;
padding: 4px;
}

View file

@ -0,0 +1,59 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
FormLabel,
Radio,
RadioGroup
} from '@mui/material';
interface Props {
open: boolean;
handleOnClose: () => void;
launchQuiz: () => void;
setQuizMode: (mode: 'teacher' | 'student') => void;
}
const LaunchQuizDialog: React.FC<Props> = ({ open, handleOnClose, launchQuiz, setQuizMode }) => {
return (
<Dialog open={open} onClose={handleOnClose}>
<DialogTitle sx={{ fontWeight: 'bold', fontSize: 24 }}>
Options de lancement du quiz
</DialogTitle>
<DialogContent>
<FormControl>
<FormLabel>Rythme du quiz</FormLabel>
<RadioGroup defaultValue="teacher" name="radio-buttons-group">
<FormControlLabel
value="teacher"
control={<Radio />}
label="Rythme du professeur"
onChange={() => setQuizMode('teacher')}
/>
<FormControlLabel
value="student"
control={<Radio />}
label="Rythme de l'étudiant"
onChange={() => setQuizMode('student')}
/>
</RadioGroup>
</FormControl>
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={handleOnClose}>
Annuler
</Button>
<Button variant="contained" onClick={launchQuiz}>
Lancer
</Button>
</DialogActions>
</Dialog>
);
};
export default LaunchQuizDialog;

View file

@ -0,0 +1,390 @@
// LiveResults.tsx
import React, { useEffect, useMemo, useState } from 'react';
import { Socket } from 'socket.io-client';
import { GIFTQuestion } from 'gift-pegjs';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCheck, faCircleXmark } from '@fortawesome/free-solid-svg-icons';
import { QuestionType } from '../../Types/QuestionType';
import './liveResult.css';
import {
FormControlLabel,
FormGroup,
Paper,
Switch,
Table,
TableBody,
TableCell,
TableFooter,
TableHead,
TableRow
} from '@mui/material';
import Latex from 'react-latex';
import { UserType } from '../../Types/UserType';
interface LiveResultsProps {
socket: Socket | null;
questions: QuestionType[];
showSelectedQuestion: (index: number) => void;
quizMode: 'teacher' | 'student';
students: UserType[]
}
interface Answer {
answer: string | number | boolean;
isCorrect: boolean;
idQuestion: number;
}
interface StudentResult {
username: string;
idUser: string;
answers: Answer[];
}
const LiveResults: React.FC<LiveResultsProps> = ({ socket, questions, showSelectedQuestion, students }) => {
const [showUsernames, setShowUsernames] = useState<boolean>(false);
const [showCorrectAnswers, setShowCorrectAnswers] = useState<boolean>(false);
const [studentResults, setStudentResults] = useState<StudentResult[]>([]);
const maxQuestions = questions.length;
useEffect(() => {
// Set student list before starting
let newStudents:StudentResult[] = [];
for (const student of students as UserType[]) {
newStudents.push( { username: student.name, idUser: student.id, answers: [] } )
}
setStudentResults(newStudents);
}, [])
useEffect(() => {
if (socket) {
const submitAnswerHandler = ({
idUser,
username,
answer,
idQuestion
}: {
idUser: string;
username: string;
answer: string | number | boolean;
idQuestion: number;
}) => {
setStudentResults((currentResults) => {
const userIndex = currentResults.findIndex(
(result) => result.idUser === idUser
);
const isCorrect = checkIfIsCorrect(answer, idQuestion);
if (userIndex !== -1) {
const newResults = [...currentResults];
newResults[userIndex].answers.push({ answer, isCorrect, idQuestion });
return newResults;
} else {
return [
...currentResults,
{ idUser, username, answers: [{ answer, isCorrect, idQuestion }] }
];
}
});
};
socket.on('submit-answer', submitAnswerHandler);
return () => {
socket.off('submit-answer');
};
}
}, [socket]);
const getStudentGrade = (student: StudentResult): 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;
studentResults.forEach((student) => {
classTotal += getStudentGrade(student);
});
return classTotal / studentResults.length;
}, [studentResults]);
const getCorrectAnswersPerQuestion = (index: number): number => {
return (
(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 (
<div>
<div className="action-bar mb-1">
<div className="text-2xl text-bold">Résultats du quiz</div>
<FormGroup row>
<FormControlLabel
label={<div className="text-sm">Afficher les noms</div>}
control={
<Switch
value={showUsernames}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setShowUsernames(e.target.checked)
}
/>
}
/>
<FormControlLabel
label={<div className="text-sm">Afficher les réponses</div>}
control={
<Switch
value={showCorrectAnswers}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setShowCorrectAnswers(e.target.checked)
}
/>
}
/>
</FormGroup>
</div>
<Table size="small" stickyHeader component={Paper}>
<TableHead>
<TableRow>
<TableCell
sx={{
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)'
}}
>
<div className="text-base text-bold">Nom d'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
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>
{studentResults.map((student) => (
<TableRow key={student.idUser}>
<TableCell
sx={{
borderStyle: 'solid',
borderWidth: 1,
borderColor: 'rgba(224, 224, 224, 1)'
}}
>
<div className="text-base">
{showUsernames ? student.username : '******'}
</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 ? (
<Latex>{answerText}</Latex>
) : 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 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)'
}}
>
{studentResults.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)'
}}
>
{studentResults.length > 0 ? `${classAverage.toFixed()} %` : '-'}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
);
};
export default LiveResults;

View file

@ -0,0 +1,14 @@
.correct-answer {
background-color: lightgreen;
}
.incorrect-answer {
background-color: lightcoral;
}
.present-results-title {
margin-top: 8vh;
margin-bottom: 2vh;
font-size: 2rem;
font-weight: 500;
}

View file

@ -0,0 +1,18 @@
import { CircularProgress } from '@mui/material';
import React from 'react';
import './loadingCircle.css';
interface Props {
text: string;
}
const LoadingCircle: React.FC<Props> = ({ text }) => {
return (
<div className="loading-circle">
<div className="text-base">{text}</div>
<CircularProgress />
</div>
);
};
export default LoadingCircle;

View file

@ -0,0 +1,5 @@
.loading-circle {
display: flex;
flex-direction: column;
align-items: center;
}

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