Merge pull request #103 from ets-cfuhrman-pfe/fuhrmanator/issue102

Actualiser l'aide pour exemples avec markdown, images, etc.
This commit is contained in:
Christopher (Cris) Fuhrman 2024-09-17 19:03:32 -04:00 committed by GitHub
commit cfee8a213a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 182 additions and 234 deletions

View file

@ -7,6 +7,7 @@ describe('Editor Component', () => {
const mockOnEditorChange = jest.fn(); const mockOnEditorChange = jest.fn();
const sampleProps = { const sampleProps = {
label: 'Sample Label',
initialValue: 'Sample Initial Value', initialValue: 'Sample Initial Value',
onEditorChange: mockOnEditorChange onEditorChange: mockOnEditorChange
}; };
@ -29,6 +30,7 @@ describe('Editor Component', () => {
it('updates editor value when initialValue prop changes', () => { it('updates editor value when initialValue prop changes', () => {
const updatedProps = { const updatedProps = {
label: 'Updated Label',
initialValue: 'Updated Initial Value', initialValue: 'Updated Initial Value',
onEditorChange: mockOnEditorChange onEditorChange: mockOnEditorChange
}; };
@ -43,6 +45,7 @@ describe('Editor Component', () => {
test('should call change text with the correct value on textarea change', () => { test('should call change text with the correct value on textarea change', () => {
const updatedProps = { const updatedProps = {
label: 'Updated Label',
initialValue: 'Updated Initial Value', initialValue: 'Updated Initial Value',
onEditorChange: mockOnEditorChange onEditorChange: mockOnEditorChange
}; };
@ -58,6 +61,7 @@ describe('Editor Component', () => {
test('should call onEditorChange with an empty string if textarea value is falsy', () => { test('should call onEditorChange with an empty string if textarea value is falsy', () => {
const updatedProps = { const updatedProps = {
label: 'Updated Label',
initialValue: 'Updated Initial Value', initialValue: 'Updated Initial Value',
onEditorChange: mockOnEditorChange onEditorChange: mockOnEditorChange
}; };

View file

@ -28,7 +28,7 @@ describe('QuizForm Component', () => {
); );
expect(screen.queryByText('Éditeur de quiz')).toBeInTheDocument(); expect(screen.queryByText('Éditeur de quiz')).toBeInTheDocument();
expect(screen.queryByText('Éditeur')).toBeInTheDocument(); // expect(screen.queryByText('Éditeur')).toBeInTheDocument();
expect(screen.queryByText('Prévisualisation')).toBeInTheDocument(); expect(screen.queryByText('Prévisualisation')).toBeInTheDocument();
}); });

View file

@ -1,13 +1,15 @@
// Editor.tsx // Editor.tsx
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import './editor.css'; import './editor.css';
import { TextareaAutosize } from '@mui/material';
interface EditorProps { interface EditorProps {
label: string;
initialValue: string; initialValue: string;
onEditorChange: (value: string) => void; onEditorChange: (value: string) => void;
} }
const Editor: React.FC<EditorProps> = ({ initialValue, onEditorChange }) => { const Editor: React.FC<EditorProps> = ({ initialValue, onEditorChange, label }) => {
const [value, setValue] = useState(initialValue); const [value, setValue] = useState(initialValue);
const editorRef = useRef<HTMLTextAreaElement | null>(null); const editorRef = useRef<HTMLTextAreaElement | null>(null);
@ -18,14 +20,17 @@ const Editor: React.FC<EditorProps> = ({ initialValue, onEditorChange }) => {
} }
return ( return (
<div> <label>
<textarea <h4>{label}</h4>
<TextareaAutosize
id="editor-textarea"
ref={editorRef} ref={editorRef}
onChange={handleEditorChange} onChange={handleEditorChange}
value={value} value={value}
className="editor" className="editor"
></textarea> minRows={5}
</div> />
</label>
); );
}; };

View file

@ -21,18 +21,18 @@ const GiftCheatSheet: React.FC = () => {
}; };
const QuestionVraiFaux = "2+2 \\= 4 ? {T\n}// Vous pouvez utiliser les valeurs {T}, {F}, {TRUE} et {FALSE}"; const QuestionVraiFaux = "2+2 \\= 4 ? {T\n}// Utilisez les valeurs {T}, {F}, {TRUE} et {FALSE}";
const QuestionChoixMul = "Quelle ville est la capitale du Canada? {\n~ Toronto\n~ Montréal\n= Ottawa #Bonne réponse!\n}// La bonne réponse est Ottawa"; const QuestionChoixMul = "Quelle ville est la capitale du Canada? {\n~ Toronto\n~ Montréal\n= Ottawa #Bonne réponse!\n}// La bonne réponse est Ottawa";
const QuestionChoixMulMany = "Quelles villes trouve-t-on au Canada? { \n~ %33.3% Montréal \n~ %33.3% Ottawa \n~ %33.3% Vancouver \n~ %-100% New York \n~ %-100% Paris \n#### La bonne réponse est Montréal, Ottawa et Vancouver \n} //On utilise le signe ~ pour toutes les réponses. On doit indiquer le pourcentage de chaque réponse"; const QuestionChoixMulMany = "Quelles villes trouve-t-on au Canada? { \n~ %33.3% Montréal \n ~ %33.3% Ottawa \n ~ %33.3% Vancouver \n ~ %-100% New York \n ~ %-100% Paris \n#### La bonne réponse est Montréal, Ottawa et Vancouver \n}\n// Utilisez le signe ~ pour toutes les réponses.\n// On doit indiquer le pourcentage de chaque réponse.";
const QuestionCourte ="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."; const QuestionCourte ="Avec quoi ouvre-t-on une porte? { \n= clé \n= clef \n}\n// Permet de fournir plusieurs bonnes réponses.\n// Note: La casse n'est pas prise en compte.";
const QuestionNum ="Question {#=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"; const QuestionNum ="Question {#=Nombre\n} //OU \nQuestion {#=Nombre:Tolérance\n} // OU \nQuestion {#=PetitNombre..GrandNombre\n}\n// La tolérance est un pourcentage.\n// La réponse doit être comprise entre PetitNombre et GrandNombre";
return ( return (
<div className="gift-cheat-sheet"> <div className="gift-cheat-sheet">
<h2 className="subtitle">Informations pratiques sur l'éditeur</h2> <h2 className="subtitle">Informations pratiques sur l'éditeur</h2>
<span> <span>
L'éditeur utilise le format GIFT (General Import Format Template) créé pour la 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 plateforme Moodle afin de générer les mini-tests. Ci-dessous vous pouvez retrouver la
syntaxe pour chaque type de question ainsi que les champs optionnels : syntaxe pour chaque type de question&nbsp;:
</span> </span>
<div className="question-type"> <div className="question-type">
<h4>1. Questions Vrai/Faux</h4> <h4>1. Questions Vrai/Faux</h4>
@ -69,7 +69,7 @@ const GiftCheatSheet: React.FC = () => {
</div> </div>
<div className="question-type"> <div className="question-type">
<h4>4. Questions à reponse courte</h4> <h4>4. Questions à réponse courte</h4>
<pre> <pre>
<code className="question-code-block selectable-text"> <code className="question-code-block selectable-text">
{QuestionCourte} {QuestionCourte}
@ -79,7 +79,7 @@ const GiftCheatSheet: React.FC = () => {
</div> </div>
<div className="question-type"> <div className="question-type">
<h4> 5. Questions numériques </h4> <h4> 5. Question numérique </h4>
<pre> <pre>
<code className="question-code-block selectable-text"> <code className="question-code-block selectable-text">
{ {
@ -139,7 +139,7 @@ const GiftCheatSheet: React.FC = () => {
<div className="question-type"> <div className="question-type">
<h4> 8. LaTeX et Markdown</h4> <h4> 8. LaTeX et Markdown</h4>
<p> <p>
Les format LaTeX et markdown sont supportés dans cette application. Vous devez cependant penser Les formats LaTeX et Markdown sont supportés dans cette application. Vous devez cependant penser
à 'échapper' les caractères spéciaux mentionnés plus haut. à 'échapper' les caractères spéciaux mentionnés plus haut.
</p> </p>
<p>Exemple d'équation:</p> <p>Exemple d'équation:</p>
@ -154,18 +154,30 @@ const GiftCheatSheet: React.FC = () => {
</div> </div>
<div className="question-type"> <div className="question-type">
<h4> 9. inserer une image </h4> <h4> 9. Images </h4>
<p>Pour insérer une image, vous devez utiliser la syntaxe suivante:</p> <p>Pour insérer une image dans une question ou dans une réponse, vous devez utiliser la syntaxe suivante:</p>
<pre> <pre>
<code className="question-code-block"> <code className="question-code-block">
{'<img '} {'!['}
<span className="code-comment">{`un_URL_d_image`}</span> <span className="code-comment">{`text alternatif`}</span>
{' >'} {']('}
<span className="code-comment">{`URL-de-l'image`}</span>
{' "'}
<span className="code-comment">{`texte de l'infobulle`}</span>
{'")'}
</code> </code>
</pre> </pre>
<p>Exemple d'une question Vrai/Faux avec l'image d'un chat:</p>
<pre>
<code className="question-code-block">
{'[markdown]Ceci est un chat: \n![Image de chat](https\\://www.example.com\\:8000/chat.jpg "Chat mignon")\n{T}'}
</code>
</pre>
<p>Note&nbsp;: les images étant spécifiées avec la syntaxe Markdown dans GIFT, on doit échapper les caractères spéciales (:) dans l'URL de l'image.</p>
<p>Note&nbsp;: On ne peut utiliser les images dans les messages de rétroaction (GIFT), car les rétroactions ne supportent pas le texte avec formatage (Markdown).</p>
<p style={{ color: 'red' }}> <p style={{ color: 'red' }}>
Attention nous ne supportons pas encore les images en tant que réponses à une Attention: l'ancienne fonctionnalité avec les balises <code>{'<img>'}</code> n'est plus
question supportée.
</p> </p>
</div> </div>
@ -184,5 +196,4 @@ const GiftCheatSheet: React.FC = () => {
); );
}; };
export default GiftCheatSheet; export default GiftCheatSheet;

View file

@ -1,5 +1,5 @@
.gift-cheat-sheet { .gift-cheat-sheet {
width: 30vw; /* width: 30vw; */
height: 100%; height: 100%;
} }
.subtitle { .subtitle {

View file

@ -25,12 +25,39 @@ body {
} }
.content { .content {
max-width: 1000px;
margin: auto; margin: auto;
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 100%;
}
/* Media query for phones in portrait mode (max-width: 767px) */
@media (max-width: 767px) {
.content {
max-width: 100%; /* Full width for small screens */
}
}
/* Media query for tablets (min-width: 768px) */
@media (min-width: 768px) {
.content {
max-width: 750px;
}
}
/* Media query for small desktops (min-width: 992px) */
@media (min-width: 992px) {
.content {
max-width: 970px;
}
}
/* Media query for large desktops (min-width: 1200px) */
@media (min-width: 1800px) {
.content {
max-width: 1770px;
}
} }
.app { .app {
@ -55,159 +82,3 @@ main {
padding: 2rem 2rem; padding: 2rem 2rem;
} }
/*
main {
height: 85%;
width: 100%;
display: flex;
flex-direction: column;
margin-top: 5rem;
}
.wrapper {
height: 100%;
}
#root {
height: 100%;
overflow: hidden;
}
.app {
height: 100%;
padding: 1rem;
}
.logo {
position: absolute;
cursor: pointer;
left: 0.5rem;
top: 0.5rem;
}
.center {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.title {
font-size: xx-large;
font-weight: 600;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
}
.text-base {
font-size: 1rem;
line-height: 1.5rem;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.text-2xl {
font-size: 1.5rem;
line-height: 1.75rem;
}
.center-v-align {
display: flex;
flex-direction: column;
align-items: center;
}
.center-v-align > * {
padding: 10px;
}
.center-h-align {
display: flex;
justify-content: center;
align-items: center;
}
.center-h-align > * {
padding: 10px;
}
.text-bold {
font-weight: bold;
}
.end-h-align {
display: flex;
align-items: center;
justify-content: space-between;
}
.w-full {
width: 100%;
}
.h-full {
height: 100%;
}
.error-text {
color: red;
}
.page-title {
font-size: 36pt;
font-weight: 600;
text-align: center;
}
.quit-btn {
position: absolute;
right: 1rem;
top: 1rem;
}
.overflow-auto {
overflow: auto;
}
.mt-1\/2 {
margin-top: 0.5rem;
}
.mb-1 {
margin-bottom: 1rem;
}
.mb-2 {
margin-bottom: 2rem;
}
.mb-3 {
margin-bottom: 3rem;
}
.mb-4 {
margin-bottom: 4rem;
}
.mb-5 {
margin-bottom: 5rem;
}
.text-center {
text-align: center;
}
.blue {
color: #5271ff;
}
.w-12 {
width: 13rem;
} */

View file

@ -370,7 +370,7 @@ const Dashboard: React.FC = () => {
<TextField <TextField
onChange={handleSearch} onChange={handleSearch}
value={searchTerm} value={searchTerm}
placeholder="Rechercher un quiz" placeholder="Rechercher un quiz par son titre"
fullWidth fullWidth
InputProps={{ InputProps={{
endAdornment: ( endAdornment: (

View file

@ -1,5 +1,5 @@
// EditorQuiz.tsx // EditorQuiz.tsx
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { FolderType } from '../../../Types/FolderType'; import { FolderType } from '../../../Types/FolderType';
@ -11,11 +11,12 @@ import GIFTTemplatePreview from '../../../components/GiftTemplate/GIFTTemplatePr
import { QuizType } from '../../../Types/QuizType'; import { QuizType } from '../../../Types/QuizType';
import './editorQuiz.css'; import './editorQuiz.css';
import { Button, TextField, NativeSelect, IconButton } from '@mui/material'; import { Button, TextField, NativeSelect, Divider, Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material';
import { Send } from '@mui/icons-material';
import ReturnButton from '../../../components/ReturnButton/ReturnButton'; import ReturnButton from '../../../components/ReturnButton/ReturnButton';
import ApiService from '../../../services/ApiService'; import ApiService from '../../../services/ApiService';
import { escapeForGIFT } from '../../../utils/giftUtils';
import { Upload } from '@mui/icons-material';
interface EditQuizParams { interface EditQuizParams {
id: string; id: string;
@ -37,6 +38,8 @@ const QuizForm: React.FC = () => {
const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => { const handleSelectFolder = (event: React.ChangeEvent<HTMLSelectElement>) => {
setSelectedFolder(event.target.value); setSelectedFolder(event.target.value);
}; };
const fileInputRef = useRef<HTMLInputElement>(null);
const [dialogOpen, setDialogOpen] = useState(false);
useEffect(() => { useEffect(() => {
const fetchData = async () => { const fetchData = async () => {
@ -135,8 +138,13 @@ const QuizForm: React.FC = () => {
try { try {
const inputElement = document.getElementById('file-input') as HTMLInputElement; const inputElement = document.getElementById('file-input') as HTMLInputElement;
if (!inputElement?.files || inputElement.files.length === 0) {
setDialogOpen(true);
return;
}
if (!inputElement.files || inputElement.files.length === 0) { if (!inputElement.files || inputElement.files.length === 0) {
window.alert("Veuillez d'abord choisir un fichier à télécharger") window.alert("Veuillez d'abord choisir une image à téléverser.")
return; return;
} }
@ -149,6 +157,11 @@ const QuizForm: React.FC = () => {
} }
setImageLinks(prevLinks => [...prevLinks, imageUrl]); setImageLinks(prevLinks => [...prevLinks, imageUrl]);
// Reset the file input element
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (error) { } catch (error) {
window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`) window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`)
} }
@ -172,73 +185,103 @@ const QuizForm: React.FC = () => {
<div className='dumb'></div> <div className='dumb'></div>
</div> </div>
{/* <h2 className="subtitle">Éditeur</h2> */}
<TextField
onChange={handleQuizTitleChange}
value={quizTitle}
placeholder="Titre du quiz"
label="Titre du quiz"
fullWidth
/>
<label>Choisir un dossier:
<NativeSelect
id="select-folder"
color="primary"
value={selectedFolder}
onChange={handleSelectFolder}
disabled={!isNewQuiz}
style={{ marginBottom: '16px' }} // Ajout de marge en bas
>
<option disabled value=""> Choisir un dossier... </option>
{folders.map((folder: FolderType) => (
<option value={folder._id} key={folder._id}> {folder.title} </option>
))}
</NativeSelect></label>
<Button variant="contained" onClick={handleQuizSave}>
Enregistrer
</Button>
<Divider style={{ margin: '16px 0' }} />
<div className='editSection'> <div className='editSection'>
<div className='edit'> <div className='edit'>
<h2 className="subtitle">Éditeur</h2> <Editor
<TextField label="Contenu GIFT du quiz:"
onChange={handleQuizTitleChange} initialValue={value}
value={quizTitle} onEditorChange={handleUpdatePreview} />
placeholder="Titre du quiz"
fullWidth
/>
<NativeSelect
id="select-folder"
color="primary"
value={selectedFolder}
onChange={handleSelectFolder}
disabled={!isNewQuiz}
>
<option disabled value=""> Choisir un dossier... </option>
{folders.map((folder: FolderType) => (
<option value={folder._id} key={folder._id}> {folder.title} </option>
))}
</NativeSelect>
<Editor initialValue={value} onEditorChange={handleUpdatePreview} />
<div className='images'> <div className='images'>
<div className='upload'> <div className='upload'>
<label className="dropArea"> <label className="dropArea">
<input type="file" id="file-input" className="file-input" accept="image/jpeg" multiple /> <input type="file" id="file-input" className="file-input"
accept="image/jpeg, image/png"
multiple
ref={fileInputRef} />
<Button
variant="outlined"
aria-label='Téléverser'
onClick={handleSaveImage}>
Téléverser <Upload />
</Button>
</label> </label>
<Dialog
<IconButton open={dialogOpen}
color="primary" onClose={() => setDialogOpen(false)} >
onClick={handleSaveImage} <DialogTitle>Erreur</DialogTitle>
> <Send /> </IconButton> <DialogContent>
Veuillez d'abord choisir une image à téléverser.
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)} color="primary">
OK
</Button>
</DialogActions>
</Dialog>
</div> </div>
<h2 className="subtitle">Mes images :</h2> <h4>Mes images :</h4>
<div> <div>
<div>(Cliquez sur un lien pour le copier)</div>
<ul> <ul>
{imageLinks.map((link, index) => ( {imageLinks.map((link, index) => {
<li key={index}> const imgTag = `![alt_text](${escapeForGIFT(link)} "texte de l'infobulle")`;
<code return (
onClick={() => handleCopyToClipboard(`<img ${link} >`)}> <li key={index}>
{`<img ${link} >`} <code
</code> onClick={() => handleCopyToClipboard(imgTag)}>
</li> {imgTag}
))} </code>
</li>
);
})}
</ul> </ul>
</div> </div>
</div> </div>
<Button variant="contained" onClick={handleQuizSave}>
Enregistrer
</Button>
<GiftCheatSheet /> <GiftCheatSheet />
</div> </div>
<div className='preview'> <div className='preview'>
<div className="preview-column"> <div className="preview-column">
<h2 className="subtitle">Prévisualisation</h2> <h4>Prévisualisation</h4>
<div> <div>
<GIFTTemplatePreview questions={filteredValue} /> <GIFTTemplatePreview questions={filteredValue} />
</div> </div>

View file

@ -44,10 +44,20 @@
.quizEditor .editSection .edit .upload { .quizEditor .editSection .edit .upload {
display: flex; display: flex;
width: 100%; width: 100%;
flex-direction: row;
align-items: center; align-items: right;
justify-content: center; justify-content: center;
gap: 8px;
flex-wrap: wrap;
} }
@media (max-width: 600px) {
.upload .dropArea {
flex-direction: column;
/* align-items: stretch; */
}
}
input[type="file"] { input[type="file"] {
height: 100%; height: 100%;
width: 100%; width: 100%;

View file

@ -0,0 +1,4 @@
export function escapeForGIFT(link: string): string {
const specialChars = /[{}#~=<>\:]/g;
return link.replace(specialChars, (match) => `\\${match}`);
}