From c6c830ef741e4d514d249286e89910b310e5a40d Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Sat, 22 Feb 2025 17:56:53 -0500 Subject: [PATCH 01/25] init gestion img --- server/__tests__/image.test.js | 83 ++++++++++++++++++++++++++++++++++ server/controllers/images.js | 17 +++++++ server/models/images.js | 22 +++++++++ 3 files changed, 122 insertions(+) diff --git a/server/__tests__/image.test.js b/server/__tests__/image.test.js index 488d27b..b220e2c 100644 --- a/server/__tests__/image.test.js +++ b/server/__tests__/image.test.js @@ -7,6 +7,9 @@ // const BASE_URL = '/image' +const Images = require('../models/images'); +const ObjectId = require('mongodb').ObjectId; + describe.skip("POST /upload", () => { describe("when the jwt is not sent", () => { @@ -64,3 +67,83 @@ describe.skip("GET /get", () => { }) }) + +describe('Images', () => { + let db; + let images; + let dbConn; + let mockImagesCollection; + + beforeEach(() => { + mockImagesCollection = { + insertOne: jest.fn().mockResolvedValue({ insertedId: 'image123' }), + findOne: jest.fn() + }; + + dbConn = { + collection: jest.fn().mockReturnValue(mockImagesCollection) + }; + + db = { + connect: jest.fn().mockResolvedValue(), + getConnection: jest.fn().mockReturnValue(dbConn) + }; + + images = new Images(db); + }); + + describe('upload', () => { + it('should upload an image and return the inserted ID', async () => { + const testFile = { originalname: 'test.png', buffer: Buffer.from('dummydata'), mimetype: 'image/png' }; + const userId = 'user123'; + + const result = await images.upload(testFile, userId); + + expect(db.connect).toHaveBeenCalled(); + expect(db.getConnection).toHaveBeenCalled(); + expect(dbConn.collection).toHaveBeenCalledWith('images'); + expect(mockImagesCollection.insertOne).toHaveBeenCalledWith({ + userId: userId, + file_name: 'test.png', + file_content: testFile.buffer.toString('base64'), + mime_type: 'image/png', + created_at: expect.any(Date) + }); + expect(result).toBe('image123'); + }); + }); + + describe('get', () => { + it('should retrieve the image if found', async () => { + const imageId = '65d9a8f9b5e8d1a5e6a8c9f0'; + const testImage = { + file_name: 'test.png', + file_content: Buffer.from('dummydata').toString('base64'), + mime_type: 'image/png' + }; + mockImagesCollection.findOne.mockResolvedValue(testImage); + + const result = await images.get(imageId); + + expect(db.connect).toHaveBeenCalled(); + expect(db.getConnection).toHaveBeenCalled(); + expect(dbConn.collection).toHaveBeenCalledWith('images'); + expect(mockImagesCollection.findOne).toHaveBeenCalledWith({ _id: ObjectId.createFromHexString(imageId) }); + expect(result).toEqual({ + file_name: 'test.png', + file_content: Buffer.from('dummydata'), + mime_type: 'image/png' + }); + }); + + it('should return null if image is not found', async () => { + const imageId = '65d9a8f9b5e8d1a5e6a8c9f0'; + mockImagesCollection.findOne.mockResolvedValue(null); + + const result = await images.get(imageId); + + expect(result).toBeNull(); + }); + }); +}); + diff --git a/server/controllers/images.js b/server/controllers/images.js index b77ed96..ff41d5c 100644 --- a/server/controllers/images.js +++ b/server/controllers/images.js @@ -50,6 +50,23 @@ class ImagesController { } }; + //TODO TEST + getAll = async (req, res, next) => { + try { + + const images = await this.images.getAll(); + + if (!images || images.length === 0) { + throw new AppError(IMAGE_NOT_FOUND); + } + + res.setHeader('Content-Type', 'application/json'); + return res.status(200).json(imagesName); + } catch (error) { + return next(error); + } + }; + } module.exports = ImagesController; diff --git a/server/models/images.js b/server/models/images.js index 67a6583..8713424 100644 --- a/server/models/images.js +++ b/server/models/images.js @@ -42,6 +42,28 @@ class Images { }; } + //TODO TEST + async getAll() { + await this.db.connect() + const conn = this.db.getConnection(); + + const imagesCollection = conn.collection('images'); + + const result = await imagesCollection.find({}); + + if (!result) return null; + + //TODO latency issues -> images > 20 + const imagesName = result.map(image => ({ + id: image.id, + file_name: image.file_name, + file_content: Buffer.from(image.file_content, 'base64'), + mime_type: image.mime_type + })); + + return imagesName; + } + } module.exports = Images; From ee5e09698471baee162530e2540ac60a80560f53 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Sat, 22 Feb 2025 18:08:38 -0500 Subject: [PATCH 02/25] added comments --- server/models/images.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/server/models/images.js b/server/models/images.js index 8713424..68c9380 100644 --- a/server/models/images.js +++ b/server/models/images.js @@ -54,6 +54,16 @@ class Images { if (!result) return null; //TODO latency issues -> images > 20 + // USE pagination + /* + app.get('/images', (req, res) => { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; + + const images = getImagesFromDatabase(page, limit); + res.json(images); + }); + */ const imagesName = result.map(image => ({ id: image.id, file_name: image.file_name, From e7c3c84b8018622bc3ca40e5e7af2397050dbed1 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Mon, 24 Feb 2025 20:26:30 -0500 Subject: [PATCH 03/25] UC38 - gestion images backend fin --- server/__tests__/image.test.js | 36 ++++++++++++++++++++++++++++++++-- server/controllers/images.js | 11 ++++++----- server/models/images.js | 21 ++++---------------- server/routers/images.js | 1 + 4 files changed, 45 insertions(+), 24 deletions(-) diff --git a/server/__tests__/image.test.js b/server/__tests__/image.test.js index b220e2c..8230b61 100644 --- a/server/__tests__/image.test.js +++ b/server/__tests__/image.test.js @@ -77,7 +77,8 @@ describe('Images', () => { beforeEach(() => { mockImagesCollection = { insertOne: jest.fn().mockResolvedValue({ insertedId: 'image123' }), - findOne: jest.fn() + findOne: jest.fn(), + find: jest.fn().mockReturnValue({ sort: jest.fn().mockReturnValue([]) }) }; dbConn = { @@ -145,5 +146,36 @@ describe('Images', () => { expect(result).toBeNull(); }); }); -}); + describe('getAll', () => { + it('should retrieve a paginated list of images', async () => { + const mockImages = [ + { id: '1', file_name: 'image1.png', file_content: Buffer.from('data1').toString('base64'), mime_type: 'image/png' }, + { id: '2', file_name: 'image2.png', file_content: Buffer.from('data2').toString('base64'), mime_type: 'image/png' } + ]; + mockImagesCollection.find.mockReturnValue({ sort: jest.fn().mockReturnValue(mockImages) }); + + const result = await images.getAll(1, 10); + + expect(db.connect).toHaveBeenCalled(); + expect(db.getConnection).toHaveBeenCalled(); + expect(dbConn.collection).toHaveBeenCalledWith('images'); + expect(mockImagesCollection.find).toHaveBeenCalledWith({}); + expect(result.length).toEqual(mockImages.length); + expect(result).toEqual([ + { id: '1', file_name: 'image1.png', file_content: Buffer.from('data1'), mime_type: 'image/png' }, + { id: '2', file_name: 'image2.png', file_content: Buffer.from('data2'), mime_type: 'image/png' } + ]); + }); + + it('should return null if not images is not found', async () => { + mockImagesCollection.find.mockReturnValue({ sort: jest.fn().mockReturnValue(undefined) }); + const result = await images.getAll(1, 10); + expect(db.connect).toHaveBeenCalled(); + expect(db.getConnection).toHaveBeenCalled(); + expect(dbConn.collection).toHaveBeenCalledWith('images'); + expect(mockImagesCollection.find).toHaveBeenCalledWith({}); + expect(result).toEqual(null); + }); + }); +}); \ No newline at end of file diff --git a/server/controllers/images.js b/server/controllers/images.js index ff41d5c..877eb7d 100644 --- a/server/controllers/images.js +++ b/server/controllers/images.js @@ -50,18 +50,19 @@ class ImagesController { } }; - //TODO TEST - getAll = async (req, res, next) => { + getImages = async (req, res, next) => { try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 10; - const images = await this.images.getAll(); + const imagesBit = await this.images.getImages(page, limit); - if (!images || images.length === 0) { + if (!imagesBit || imagesBit.length === 0) { throw new AppError(IMAGE_NOT_FOUND); } res.setHeader('Content-Type', 'application/json'); - return res.status(200).json(imagesName); + return res.status(200).json(imagesBit); } catch (error) { return next(error); } diff --git a/server/models/images.js b/server/models/images.js index 68c9380..66d6084 100644 --- a/server/models/images.js +++ b/server/models/images.js @@ -42,38 +42,25 @@ class Images { }; } - //TODO TEST - async getAll() { + async getImages(page, limit) { await this.db.connect() const conn = this.db.getConnection(); const imagesCollection = conn.collection('images'); - const result = await imagesCollection.find({}); + const result = await imagesCollection.find({}).sort({created_at: 1}); if (!result) return null; - //TODO latency issues -> images > 20 - // USE pagination - /* - app.get('/images', (req, res) => { - const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 10; - - const images = getImagesFromDatabase(page, limit); - res.json(images); - }); - */ - const imagesName = result.map(image => ({ + const objImages = result.slice((page - 1) * limit, page * limit).map(image => ({ id: image.id, file_name: image.file_name, file_content: Buffer.from(image.file_content, 'base64'), mime_type: image.mime_type })); - return imagesName; + return objImages; } - } module.exports = Images; diff --git a/server/routers/images.js b/server/routers/images.js index 06e2830..626e6e8 100644 --- a/server/routers/images.js +++ b/server/routers/images.js @@ -12,5 +12,6 @@ const upload = multer({ storage: storage }); router.post("/upload", jwt.authenticate, upload.single('image'), asyncHandler(images.upload)); router.get("/get/:id", asyncHandler(images.get)); +router.get("/getImages", asyncHandler(images.getImages)); module.exports = router; From 16d594c61d913ad843b3e96077d3d47f41163f18 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Tue, 25 Feb 2025 17:38:23 -0500 Subject: [PATCH 04/25] FIX erreur encoding --- server/models/images.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/models/images.js b/server/models/images.js index 66d6084..a9e8306 100644 --- a/server/models/images.js +++ b/server/models/images.js @@ -48,14 +48,15 @@ class Images { const imagesCollection = conn.collection('images'); - const result = await imagesCollection.find({}).sort({created_at: 1}); + const result = await imagesCollection.find({}).sort({created_at: 1}).toArray(); if (!result) return null; const objImages = result.slice((page - 1) * limit, page * limit).map(image => ({ - id: image.id, + id: image._id, + user: image.userId, file_name: image.file_name, - file_content: Buffer.from(image.file_content, 'base64'), + file_content: image.file_content.toString('base64'), mime_type: image.mime_type })); From c9d65c00820586a9480c0461108765a5d004742f Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Sun, 2 Mar 2025 20:52:08 -0500 Subject: [PATCH 05/25] ajout front images gallery --- client/src/Types/Images.tsx | 11 +++ .../pages/Teacher/EditorQuiz/EditorQuiz.tsx | 67 ++++++++++++++++++- client/src/services/ApiService.tsx | 47 ++++++++++++- server/controllers/users.js | 7 +- server/models/images.js | 9 ++- 5 files changed, 136 insertions(+), 5 deletions(-) create mode 100644 client/src/Types/Images.tsx diff --git a/client/src/Types/Images.tsx b/client/src/Types/Images.tsx new file mode 100644 index 0000000..84e1e5a --- /dev/null +++ b/client/src/Types/Images.tsx @@ -0,0 +1,11 @@ +export interface Images { + id: string; + file_content: string; + file_name: string; + mime_type: string; +} + +export interface ImagesResponse { + images: Images[]; + total: number; +} diff --git a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx index f2c2d69..51ad681 100644 --- a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx +++ b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx @@ -16,7 +16,8 @@ import ReturnButton from 'src/components/ReturnButton/ReturnButton'; import ApiService from '../../../services/ApiService'; import { escapeForGIFT } from '../../../utils/giftUtils'; -import { Upload } from '@mui/icons-material'; +import { Upload, ImageSearch } from '@mui/icons-material'; +import { Images } from '../../../Types/Images'; interface EditQuizParams { id: string; @@ -40,7 +41,12 @@ const QuizForm: React.FC = () => { }; const fileInputRef = useRef(null); const [dialogOpen, setDialogOpen] = useState(false); + const [galleryOpen, setGalleryOpen] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false); + const [images, setImages] = useState([]); + const [totalImg, setTotalImg] = useState(0); + const [imgPage, setImgPage] = useState(1); + const [imgLimit] = useState(5); const scrollToTop = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -69,9 +75,19 @@ const QuizForm: React.FC = () => { } }; + const fetchImages = async (page: number , limit: number) => { + const data = await ApiService.getImages(page, limit); + const imgs = data.images; + const total = data.total; + + setImages(imgs as Images[]); + setTotalImg(total); + } + useEffect(() => { const fetchData = async () => { const userFolders = await ApiService.getUserFolders(); + fetchImages(1, imgLimit); setFolders(userFolders as FolderType[]); }; @@ -205,6 +221,13 @@ const QuizForm: React.FC = () => { navigator.clipboard.writeText(link); } + const handleMoreImages = async () => { + let page = imgPage; + page += 1; + setImgPage(page); + fetchImages(imgPage, imgLimit); + } + return (
@@ -290,6 +313,48 @@ const QuizForm: React.FC = () => {

Mes images :

+ + + + setDialogOpen(false)} > + Images disponibles + + +
+ {images.map((obj: Images, index) => ( +
+ {`Image + {`lien: ${obj.id}`} +
+ ))} +
+
+ + { + totalImg > 10 ? + + : + + + } + +
(Voir section
diff --git a/client/src/services/ApiService.tsx b/client/src/services/ApiService.tsx index ef124b4..22189e5 100644 --- a/client/src/services/ApiService.tsx +++ b/client/src/services/ApiService.tsx @@ -1,6 +1,7 @@ import axios, { AxiosError, AxiosResponse } from 'axios'; import { FolderType } from 'src/Types/FolderType'; +import { ImagesResponse } from '../Types/Images'; import { QuizType } from 'src/Types/QuizType'; import { ENV_VARIABLES } from 'src/constants'; @@ -65,6 +66,16 @@ class ApiService { return object.token; } + private getUserID(): string | null { + const objStr = localStorage.getItem("uid"); + + if (!objStr) { + return null; + } + + return objStr; + } + public isLoggedIn(): boolean { const token = this.getToken() @@ -141,7 +152,8 @@ class ApiService { throw new Error(`La connexion a échoué. Status: ${result.status}`); } - this.saveToken(result.data.token); + this.saveToken(result.data.result.token); + localStorage.setItem("uid", JSON.stringify(result.data.result.userId)); return true; @@ -886,7 +898,38 @@ class ApiService { return `ERROR : Une erreur inattendue s'est produite.` } } - // NOTE : Get Image pas necessaire + + + public async getImages(page: number, limit: number): Promise { + try { + const url: string = this.constructRequestUrl(`/image/getImages`); + const headers = this.constructRequestHeaders(); + const params = { page: page, limit: limit}; + + const result: AxiosResponse = await axios.get(url, { params: params, headers: headers }); + + if (result.status !== 200) { + throw new Error(`L'enregistrement a échoué. Status: ${result.status}`); + } + + console.log(result.data); + const images = result.data; + + return images; + + } catch (error) { + console.log("Error details: ", error); + + if (axios.isAxiosError(error)) { + const err = error as AxiosError; + const data = err.response?.data as { error: string } | undefined; + const msg = data?.error || 'Erreur serveur inconnue lors de la requête.'; + throw new Error(`L'enregistrement a échoué. Status: ${msg}`); + } + + throw new Error(`ERROR : Une erreur inattendue s'est produite.`); + } + } } diff --git a/server/controllers/users.js b/server/controllers/users.js index c6b5dab..57ebeeb 100644 --- a/server/controllers/users.js +++ b/server/controllers/users.js @@ -54,7 +54,12 @@ class UsersController { const token = jwt.create(user.email, user._id); - return res.status(200).json({ token }); + let result = { + token: token, + userId: user._id + } + + return res.status(200).json({ result }); } catch (error) { next(error); } diff --git a/server/models/images.js b/server/models/images.js index a9e8306..b631e0e 100644 --- a/server/models/images.js +++ b/server/models/images.js @@ -52,6 +52,8 @@ class Images { if (!result) return null; + const total = result.length; + const objImages = result.slice((page - 1) * limit, page * limit).map(image => ({ id: image._id, user: image.userId, @@ -60,7 +62,12 @@ class Images { mime_type: image.mime_type })); - return objImages; + let respObj = { + images: objImages, + total: total + } + + return respObj; } } From 0f87bab5fff9863bb771402adf503acc54633dbe Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Tue, 4 Mar 2025 19:49:18 -0500 Subject: [PATCH 06/25] FIX dialog front et perf pagination --- .../components/ImageGallery/ImageGallery.tsx | 166 ++++++++++++++++++ .../pages/Teacher/EditorQuiz/EditorQuiz.tsx | 70 ++------ server/controllers/images.js | 2 +- server/models/images.js | 14 +- 4 files changed, 191 insertions(+), 61 deletions(-) create mode 100644 client/src/components/ImageGallery/ImageGallery.tsx diff --git a/client/src/components/ImageGallery/ImageGallery.tsx b/client/src/components/ImageGallery/ImageGallery.tsx new file mode 100644 index 0000000..be4a67a --- /dev/null +++ b/client/src/components/ImageGallery/ImageGallery.tsx @@ -0,0 +1,166 @@ +import React, { useState, useEffect } from "react"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Table, + TableBody, + TableCell, + TableContainer, + TableRow, + IconButton, + Paper, + TextField, + Box +} from "@mui/material"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import CloseIcon from "@mui/icons-material/Close"; +import DeleteIcon from "@mui/icons-material/Delete"; +import EditIcon from "@mui/icons-material/Edit"; +import { Images } from "../../Types/Images"; +import ApiService from '../../services/ApiService'; + +type Props = { + galleryOpen: boolean; + admin: boolean; + setDialogOpen: React.Dispatch>; + setImageLinks: React.Dispatch>; +} + +const ImageDialog: React.FC = ({ galleryOpen, admin, setDialogOpen, setImageLinks }) => { + + const [copiedId, setCopiedId] = useState(null); + const [deleteId, setDeleteId] = useState(null); + /* const [editedFileNames, setEditedFileNames] = useState<{ [key: string]: string }>( + Object.fromEntries(images.map((img) => [img.id, img.file_name])) + ); + */ + const [editingId, setEditingId] = useState(null); + const [images, setImages] = useState([]); + const [totalImg, setTotalImg] = useState(0); + const [imgPage, setImgPage] = useState(1); + const [imgLimit] = useState(3); + + const fetchImages = async (page: number, limit: number) => { + const data = await ApiService.getImages(page, limit); + console.log(data); + setImages(data.images); + setTotalImg(data.total); + }; + + useEffect(() => { + fetchImages(imgPage, imgLimit); + }, [imgPage]); // Re-fetch images when page changes + + const handleDeleteClick = (id: string) => { + setDeleteId(id); + }; + + const handleEditClick = (id: string) => { + setEditingId(id === editingId ? null : id); + }; + + const handleFileNameChange = (id: string, newFileName: string) => { + //setEditedFileNames((prev) => ({ ...prev, [id]: newFileName })); + }; + + const onCopy = (id: string) => { + setCopiedId(id); + setImageLinks(prevLinks => [...prevLinks, id]); + }; + + const handleNextPage = () => { + if ((imgPage * imgLimit) < totalImg) { + setImgPage(prev => prev + 1); + } + }; + + const handlePrevPage = () => { + if (imgPage > 1) { + setImgPage(prev => prev - 1); + } + }; + + return ( + setDialogOpen(false)} + maxWidth="xl" // 'md' stands for medium size + > + + Images disponibles + setDialogOpen(false)} + style={{ position: "absolute", right: 8, top: 8 }} + > + + + + + + + + {images.map((obj: Images) => ( + + + {`Image + + + {admin && editingId === obj.id ? ( + handleFileNameChange(obj.id, e.target.value)} + variant="outlined" + size="small" + style={{ maxWidth: 150 }} + /> + ) : ( + obj.file_name + )} + + + {obj.id} + onCopy(obj.id)} size="small"> + + + {admin && ( + <> + handleEditClick(obj.id)} size="small" color="primary"> + + + handleDeleteClick(obj.id)} size="small" color="primary"> + + + + )} + {copiedId === obj.id && Copié!} + + + ))} + +
+
+
+ + + + + + +
+ ); +}; + +export default ImageDialog; diff --git a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx index 51ad681..bafb1d9 100644 --- a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx +++ b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx @@ -13,11 +13,11 @@ import { QuizType } from '../../../Types/QuizType'; import './editorQuiz.css'; import { Button, TextField, NativeSelect, Divider, Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material'; import ReturnButton from 'src/components/ReturnButton/ReturnButton'; +import ImageGallery from 'src/components/ImageGallery/ImageGallery'; import ApiService from '../../../services/ApiService'; import { escapeForGIFT } from '../../../utils/giftUtils'; import { Upload, ImageSearch } from '@mui/icons-material'; -import { Images } from '../../../Types/Images'; interface EditQuizParams { id: string; @@ -43,10 +43,6 @@ const QuizForm: React.FC = () => { const [dialogOpen, setDialogOpen] = useState(false); const [galleryOpen, setGalleryOpen] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false); - const [images, setImages] = useState([]); - const [totalImg, setTotalImg] = useState(0); - const [imgPage, setImgPage] = useState(1); - const [imgLimit] = useState(5); const scrollToTop = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); @@ -75,19 +71,9 @@ const QuizForm: React.FC = () => { } }; - const fetchImages = async (page: number , limit: number) => { - const data = await ApiService.getImages(page, limit); - const imgs = data.images; - const total = data.total; - - setImages(imgs as Images[]); - setTotalImg(total); - } - useEffect(() => { const fetchData = async () => { const userFolders = await ApiService.getUserFolders(); - fetchImages(1, imgLimit); setFolders(userFolders as FolderType[]); }; @@ -220,13 +206,13 @@ const QuizForm: React.FC = () => { const handleCopyToClipboard = async (link: string) => { navigator.clipboard.writeText(link); } + + const handleCopy = (imgId: string) => { + setImageLinks(prevLinks => [...prevLinks, imgId]); + console.log(imgId); + }; - const handleMoreImages = async () => { - let page = imgPage; - page += 1; - setImgPage(page); - fetchImages(imgPage, imgLimit); - } + return (
@@ -321,40 +307,14 @@ const QuizForm: React.FC = () => { Images - setDialogOpen(false)} > - Images disponibles - - -
- {images.map((obj: Images, index) => ( -
- {`Image - {`lien: ${obj.id}`} -
- ))} -
-
- - { - totalImg > 10 ? - - : - - - } - -
+ + +
(Voir section
diff --git a/server/controllers/images.js b/server/controllers/images.js index 877eb7d..c5675d4 100644 --- a/server/controllers/images.js +++ b/server/controllers/images.js @@ -53,7 +53,7 @@ class ImagesController { getImages = async (req, res, next) => { try { const page = parseInt(req.query.page) || 1; - const limit = parseInt(req.query.limit) || 10; + const limit = parseInt(req.query.limit) || 5; const imagesBit = await this.images.getImages(page, limit); diff --git a/server/models/images.js b/server/models/images.js index b631e0e..13d6ad0 100644 --- a/server/models/images.js +++ b/server/models/images.js @@ -48,13 +48,17 @@ class Images { const imagesCollection = conn.collection('images'); - const result = await imagesCollection.find({}).sort({created_at: 1}).toArray(); + + const total = await imagesCollection.countDocuments(); // Efficient total count + if (!total || total === 0) return { images: [], total }; - if (!result) return null; + const result = await imagesCollection.find({}) + .sort({ created_at: 1 }) // Ensure 'created_at' is indexed + .skip((page - 1) * limit) + .limit(limit) + .toArray(); - const total = result.length; - - const objImages = result.slice((page - 1) * limit, page * limit).map(image => ({ + const objImages = result.map(image => ({ id: image._id, user: image.userId, file_name: image.file_name, From f8357883e630104dd332d9914b9c5e3283bcc35b Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Tue, 4 Mar 2025 19:59:05 -0500 Subject: [PATCH 07/25] FIX image render --- client/src/components/ImageGallery/ImageGallery.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/src/components/ImageGallery/ImageGallery.tsx b/client/src/components/ImageGallery/ImageGallery.tsx index be4a67a..6b4301b 100644 --- a/client/src/components/ImageGallery/ImageGallery.tsx +++ b/client/src/components/ImageGallery/ImageGallery.tsx @@ -67,8 +67,9 @@ const ImageDialog: React.FC = ({ galleryOpen, admin, setDialogOpen, setIm }; const onCopy = (id: string) => { + const escLink = 'http://localhost:4400/api/image/get/'+id; setCopiedId(id); - setImageLinks(prevLinks => [...prevLinks, id]); + setImageLinks(prevLinks => [...prevLinks, escLink]); }; const handleNextPage = () => { From f055a47305e38334ee76b035ae8b1dc33d81cb81 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Thu, 6 Mar 2025 20:33:57 -0500 Subject: [PATCH 08/25] ajout test imagegallery --- .../ImageGallery/ImageGallery.test.tsx | 130 ++++++++++++++++++ .../components/ImageGallery/ImageGallery.tsx | 23 +--- .../pages/Teacher/EditorQuiz/EditorQuiz.tsx | 7 - client/src/services/ApiService.tsx | 10 -- 4 files changed, 132 insertions(+), 38 deletions(-) create mode 100644 client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx diff --git a/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx b/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx new file mode 100644 index 0000000..9604b35 --- /dev/null +++ b/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx @@ -0,0 +1,130 @@ +import React from "react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import ImageDialog from "../../../components/ImageGallery/ImageGallery"; +import ApiService from "../../../services/ApiService"; +import { Images } from "../../../Types/Images"; +import { act } from "react"; +import "@testing-library/jest-dom"; + +// Mock ApiService +jest.mock("../../../services/ApiService"); + +const mockImages: Images[] = [ + { id: "1", file_name: "image1.jpg", mime_type: "image/jpeg", file_content: "mockBase64Content1" }, + { id: "2", file_name: "image2.jpg", mime_type: "image/jpeg", file_content: "mockBase64Content2" }, + { id: "3", file_name: "image3.jpg", mime_type: "image/jpeg", file_content: "mockBase64Content3" }, +]; + +describe("ImageDialog Component", () => { + let setDialogOpenMock: jest.Mock; + let setImageLinksMock: jest.Mock; + + beforeEach(() => { + jest.clearAllMocks(); + setDialogOpenMock = jest.fn(); + setImageLinksMock = jest.fn(); + jest.spyOn(ApiService, "getImages").mockResolvedValue({ images: mockImages, total: 6 }); + }); + + test("renders the dialog when open", async () => { + + await act(async () => { + render( + + ); + }); + + expect(screen.getByText("Images disponibles")).toBeInTheDocument(); + await waitFor(() => expect(ApiService.getImages).toHaveBeenCalledWith(1, 3)); + expect(screen.getAllByRole("img")).toHaveLength(mockImages.length); + }); + + test("closes the dialog when close button is clicked", async () => { + + await act(async () => { + render( + + ); + }); + + fireEvent.click(screen.getByLabelText("close")); + expect(setDialogOpenMock).toHaveBeenCalledWith(false); + }); + + test("copies the image link when copy button is clicked", async () => { + + //const setImageLinksMock = jest.fn(); + await act(async () => { + render( + + ); + }); + + await act(async () => { + await waitFor(() => expect(screen.getAllByRole("img")).toHaveLength(mockImages.length)); + }); + + // Click the copy button + fireEvent.click(screen.getByTestId("copy-button-1")); + // Check that "Copié!" appears + expect(screen.getByText("Copié!")).toBeInTheDocument(); + }); + + test("shows edit field when admin clicks edit button", async () => { + await act(async () => { + render( + + ); + }); + + await waitFor(() => expect(ApiService.getImages).toHaveBeenCalled()); + + const editButton = screen.getByTestId("edit-button-1"); + fireEvent.click(editButton); + + expect(screen.getByDisplayValue("image1.jpg")).toBeInTheDocument(); + }); + + test("navigates to next and previous page", async () => { + await act(async () => { + render( + + ); + }); + + await waitFor(() => expect(ApiService.getImages).toHaveBeenCalledWith(1, 3)); + + fireEvent.click(screen.getByText("Suivant")); + + await waitFor(() => expect(ApiService.getImages).toHaveBeenCalledWith(2, 3)); + + fireEvent.click(screen.getByText("Précédent")); + + await waitFor(() => expect(ApiService.getImages).toHaveBeenCalledWith(1, 3)); + }); +}); \ No newline at end of file diff --git a/client/src/components/ImageGallery/ImageGallery.tsx b/client/src/components/ImageGallery/ImageGallery.tsx index 6b4301b..53b181b 100644 --- a/client/src/components/ImageGallery/ImageGallery.tsx +++ b/client/src/components/ImageGallery/ImageGallery.tsx @@ -17,7 +17,6 @@ import { } from "@mui/material"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import CloseIcon from "@mui/icons-material/Close"; -import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; import { Images } from "../../Types/Images"; import ApiService from '../../services/ApiService'; @@ -32,11 +31,6 @@ type Props = { const ImageDialog: React.FC = ({ galleryOpen, admin, setDialogOpen, setImageLinks }) => { const [copiedId, setCopiedId] = useState(null); - const [deleteId, setDeleteId] = useState(null); - /* const [editedFileNames, setEditedFileNames] = useState<{ [key: string]: string }>( - Object.fromEntries(images.map((img) => [img.id, img.file_name])) - ); - */ const [editingId, setEditingId] = useState(null); const [images, setImages] = useState([]); const [totalImg, setTotalImg] = useState(0); @@ -45,7 +39,6 @@ const ImageDialog: React.FC = ({ galleryOpen, admin, setDialogOpen, setIm const fetchImages = async (page: number, limit: number) => { const data = await ApiService.getImages(page, limit); - console.log(data); setImages(data.images); setTotalImg(data.total); }; @@ -54,18 +47,10 @@ const ImageDialog: React.FC = ({ galleryOpen, admin, setDialogOpen, setIm fetchImages(imgPage, imgLimit); }, [imgPage]); // Re-fetch images when page changes - const handleDeleteClick = (id: string) => { - setDeleteId(id); - }; - const handleEditClick = (id: string) => { setEditingId(id === editingId ? null : id); }; - const handleFileNameChange = (id: string, newFileName: string) => { - //setEditedFileNames((prev) => ({ ...prev, [id]: newFileName })); - }; - const onCopy = (id: string) => { const escLink = 'http://localhost:4400/api/image/get/'+id; setCopiedId(id); @@ -118,7 +103,6 @@ const ImageDialog: React.FC = ({ galleryOpen, admin, setDialogOpen, setIm {admin && editingId === obj.id ? ( handleFileNameChange(obj.id, e.target.value)} variant="outlined" size="small" style={{ maxWidth: 150 }} @@ -129,17 +113,14 @@ const ImageDialog: React.FC = ({ galleryOpen, admin, setDialogOpen, setIm {obj.id} - onCopy(obj.id)} size="small"> + onCopy(obj.id)} size="small" data-testid={`copy-button-${obj.id}`}> {admin && ( <> - handleEditClick(obj.id)} size="small" color="primary"> + handleEditClick(obj.id)} size="small" color="primary" data-testid={`edit-button-${obj.id}`}> - handleDeleteClick(obj.id)} size="small" color="primary"> - - )} {copiedId === obj.id && Copié!} diff --git a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx index bafb1d9..6231892 100644 --- a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx +++ b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx @@ -206,13 +206,6 @@ const QuizForm: React.FC = () => { const handleCopyToClipboard = async (link: string) => { navigator.clipboard.writeText(link); } - - const handleCopy = (imgId: string) => { - setImageLinks(prevLinks => [...prevLinks, imgId]); - console.log(imgId); - }; - - return (
diff --git a/client/src/services/ApiService.tsx b/client/src/services/ApiService.tsx index 22189e5..116765f 100644 --- a/client/src/services/ApiService.tsx +++ b/client/src/services/ApiService.tsx @@ -66,16 +66,6 @@ class ApiService { return object.token; } - private getUserID(): string | null { - const objStr = localStorage.getItem("uid"); - - if (!objStr) { - return null; - } - - return objStr; - } - public isLoggedIn(): boolean { const token = this.getToken() From add7d9954b8498fe677459f878939e1e6f41dcf0 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Mon, 10 Mar 2025 17:52:51 -0400 Subject: [PATCH 09/25] update package,json --- client/package-lock.json | 214 ++++++++++++++++----------------------- client/package.json | 1 + 2 files changed, 86 insertions(+), 129 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 3c24ffc..0031e88 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -44,6 +44,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^29.5.13", "@types/node": "^22.5.5", "@types/react": "^18.2.15", @@ -2549,7 +2550,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" @@ -2568,7 +2569,7 @@ "version": "4.12.1", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" @@ -2578,7 +2579,7 @@ "version": "0.19.1", "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.1.tgz", "integrity": "sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@eslint/object-schema": "^2.1.5", @@ -2593,7 +2594,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2604,7 +2605,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -2617,7 +2618,7 @@ "version": "0.10.0", "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" @@ -2630,7 +2631,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ajv": "^6.12.4", @@ -2654,7 +2655,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2665,7 +2666,7 @@ "version": "14.0.0", "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=18" @@ -2678,7 +2679,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -2691,7 +2692,7 @@ "version": "9.18.0", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.18.0.tgz", "integrity": "sha512-fK6L7rxcq6/z+AaQMtiFTkvbHkBLNlwyRxHpKawP0x3u9+NC6MQTnFW+AdpwC6gfHTW0051cokQgtTN2FqlxQA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2701,7 +2702,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.5.tgz", "integrity": "sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2711,7 +2712,7 @@ "version": "0.2.5", "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@eslint/core": "^0.10.0", @@ -2818,7 +2819,7 @@ "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18.18.0" @@ -2828,7 +2829,7 @@ "version": "0.16.6", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", @@ -2842,7 +2843,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=12.22" @@ -2856,7 +2857,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18.18" @@ -3859,7 +3860,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3873,7 +3873,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3887,7 +3886,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3901,7 +3899,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3915,7 +3912,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3929,7 +3925,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3943,7 +3938,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3957,7 +3951,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3971,7 +3964,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3985,7 +3977,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3999,7 +3990,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4013,7 +4003,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4027,7 +4016,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4041,7 +4029,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4055,7 +4042,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4069,7 +4055,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4083,7 +4068,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4097,7 +4081,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4138,7 +4121,7 @@ "version": "1.7.40", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.40.tgz", "integrity": "sha512-0HIzM5vigVT5IvNum+pPuST9p8xFhN6mhdIKju7qYYeNuZG78lwms/2d8WgjTJJlzp6JlPguXGrMMNzjQw0qNg==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -4180,7 +4163,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4197,7 +4179,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4214,7 +4195,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -4231,7 +4211,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4248,7 +4227,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4265,7 +4243,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4282,7 +4259,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4299,7 +4275,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4316,7 +4291,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4333,7 +4307,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -4347,14 +4320,14 @@ "version": "0.1.3", "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@swc/types": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.13.tgz", "integrity": "sha512-JL7eeCk6zWCbiYQg2xQSdLXQJl8Qoc9rXmG2cEKvHe3CKwMHwHGpfOb8frzNLmbycOo6I51qxnLnn9ESf4I20Q==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@swc/counter": "^0.1.3" @@ -4450,6 +4423,20 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -4548,7 +4535,6 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", - "dev": true, "license": "MIT" }, "node_modules/@types/graceful-fs": { @@ -4648,7 +4634,7 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@types/katex": { @@ -5029,7 +5015,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -5063,7 +5049,7 @@ "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -5138,7 +5124,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, + "devOptional": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -5952,7 +5938,7 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -6123,7 +6109,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/deepmerge": { @@ -6673,7 +6659,7 @@ "version": "9.18.0", "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.18.0.tgz", "integrity": "sha512-+waTfRWQlSbpt3KWE+CjrPPYnbq9kfZIYUqapc0uBXyjTp8aYXZDsUH16m39Ryq3NjAVP4tjuF7KaukeqoCoaA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", @@ -6831,7 +6817,7 @@ "version": "8.2.0", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", @@ -6848,7 +6834,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -6861,7 +6847,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=18.18" @@ -6875,7 +6861,7 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -6886,7 +6872,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6899,7 +6885,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -6912,7 +6898,7 @@ "version": "10.3.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.14.0", @@ -6930,7 +6916,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6956,7 +6942,7 @@ "version": "1.6.0", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -6969,7 +6955,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -7062,7 +7048,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -7097,14 +7083,14 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/fastq": { @@ -7130,7 +7116,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" @@ -7184,7 +7170,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -7201,7 +7187,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", @@ -7215,7 +7201,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/follow-redirects": { @@ -7480,7 +7466,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -7768,7 +7754,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -7814,7 +7800,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -8317,7 +8303,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/istanbul-lib-coverage": { @@ -9355,7 +9341,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -9425,7 +9411,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-parse-even-better-errors": { @@ -9438,14 +9424,14 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json5": { @@ -9517,7 +9503,7 @@ "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -9547,7 +9533,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", @@ -9567,7 +9553,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -9604,7 +9590,7 @@ "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/longest-streak": { @@ -10381,7 +10367,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/node-int64": { @@ -10561,7 +10547,7 @@ "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "deep-is": "^0.1.3", @@ -10597,7 +10583,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -10613,7 +10599,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -10681,7 +10667,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -10843,7 +10829,6 @@ "version": "8.4.47", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", - "dev": true, "funding": [ { "type": "opencollective", @@ -10872,7 +10857,6 @@ "version": "3.3.8", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", - "dev": true, "funding": [ { "type": "github", @@ -10891,7 +10875,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8.0" @@ -11380,7 +11364,6 @@ "version": "4.24.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.3.tgz", "integrity": "sha512-HBW896xR5HGmoksbi3JBDtmVzWiPAYqp7wip50hjQ67JbDz61nyoMPdqu1DvVW9asYb2M65Z20ZHsyJCMqMyDg==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.6" @@ -11582,7 +11565,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -11595,7 +11578,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -11741,7 +11724,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -11972,7 +11954,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -12240,7 +12222,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" @@ -12352,7 +12334,6 @@ "version": "5.6.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -12586,7 +12567,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, + "devOptional": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" @@ -12675,7 +12656,6 @@ "version": "5.4.14", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", - "dev": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -12813,7 +12793,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12830,7 +12809,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12847,7 +12825,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12864,7 +12841,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12881,7 +12857,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12898,7 +12873,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12915,7 +12889,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12932,7 +12905,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12949,7 +12921,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12966,7 +12937,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -12983,7 +12953,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13000,7 +12969,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13017,7 +12985,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13034,7 +13001,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13051,7 +13017,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13068,7 +13033,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13085,7 +13049,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13102,7 +13065,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13119,7 +13081,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13136,7 +13097,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13153,7 +13113,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13170,7 +13129,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13187,7 +13145,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -13201,7 +13158,6 @@ "version": "0.21.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -13411,7 +13367,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -13515,7 +13471,7 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -13672,7 +13628,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/client/package.json b/client/package.json index 2a99173..8fccab2 100644 --- a/client/package.json +++ b/client/package.json @@ -48,6 +48,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.5.0", "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.6.1", "@types/jest": "^29.5.13", "@types/node": "^22.5.5", "@types/react": "^18.2.15", From 1b321c74f907bc1696497ee45762ad195efade1c Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Mon, 10 Mar 2025 17:54:50 -0400 Subject: [PATCH 10/25] update gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 6e8de7b..e0551c4 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,7 @@ build/Release # Dependency directories node_modules/ jspm_packages/ +mongo-backup/ # Snowpack dependency directory (https://snowpack.dev/) web_modules/ From 78f6993547b920a58e8091515480778498053b05 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Mon, 10 Mar 2025 18:49:29 -0400 Subject: [PATCH 11/25] remove user-id --- server/controllers/users.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/server/controllers/users.js b/server/controllers/users.js index 57ebeeb..c6b5dab 100644 --- a/server/controllers/users.js +++ b/server/controllers/users.js @@ -54,12 +54,7 @@ class UsersController { const token = jwt.create(user.email, user._id); - let result = { - token: token, - userId: user._id - } - - return res.status(200).json({ result }); + return res.status(200).json({ token }); } catch (error) { next(error); } From 1512f320c87c510085b6725479043b363650732a Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Mon, 10 Mar 2025 19:03:58 -0400 Subject: [PATCH 12/25] tests fix --- server/__tests__/image.test.js | 60 +++++++++++++++++++++------------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/server/__tests__/image.test.js b/server/__tests__/image.test.js index 8230b61..1a56431 100644 --- a/server/__tests__/image.test.js +++ b/server/__tests__/image.test.js @@ -73,12 +73,22 @@ describe('Images', () => { let images; let dbConn; let mockImagesCollection; + let mockFindCursor; beforeEach(() => { + + mockFindCursor = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + toArray: jest.fn(), + }; + mockImagesCollection = { insertOne: jest.fn().mockResolvedValue({ insertedId: 'image123' }), findOne: jest.fn(), - find: jest.fn().mockReturnValue({ sort: jest.fn().mockReturnValue([]) }) + find: jest.fn().mockReturnValue(mockFindCursor), + countDocuments: jest.fn() }; dbConn = { @@ -147,35 +157,41 @@ describe('Images', () => { }); }); - describe('getAll', () => { + describe('getImages', () => { it('should retrieve a paginated list of images', async () => { const mockImages = [ - { id: '1', file_name: 'image1.png', file_content: Buffer.from('data1').toString('base64'), mime_type: 'image/png' }, - { id: '2', file_name: 'image2.png', file_content: Buffer.from('data2').toString('base64'), mime_type: 'image/png' } + { _id: '1', userId: 'user1', file_name: 'image1.png', file_content: Buffer.from('data1'), mime_type: 'image/png' }, + { _id: '2', userId: 'user2', file_name: 'image2.png', file_content: Buffer.from('data2'), mime_type: 'image/png' } ]; - mockImagesCollection.find.mockReturnValue({ sort: jest.fn().mockReturnValue(mockImages) }); - - const result = await images.getAll(1, 10); - + + mockImagesCollection.countDocuments.mockResolvedValue(2); + mockFindCursor.toArray.mockResolvedValue(mockImages); + + const result = await images.getImages(1, 10); + expect(db.connect).toHaveBeenCalled(); expect(db.getConnection).toHaveBeenCalled(); expect(dbConn.collection).toHaveBeenCalledWith('images'); expect(mockImagesCollection.find).toHaveBeenCalledWith({}); - expect(result.length).toEqual(mockImages.length); - expect(result).toEqual([ - { id: '1', file_name: 'image1.png', file_content: Buffer.from('data1'), mime_type: 'image/png' }, - { id: '2', file_name: 'image2.png', file_content: Buffer.from('data2'), mime_type: 'image/png' } - ]); + expect(mockFindCursor.sort).toHaveBeenCalledWith({ created_at: 1 }); + expect(mockFindCursor.skip).toHaveBeenCalledWith(0); + expect(mockFindCursor.limit).toHaveBeenCalledWith(10); + expect(result).toEqual({ + images: [ + { id: '1', user: 'user1', file_name: 'image1.png', file_content: 'ZGF0YTE=', mime_type: 'image/png' }, + { id: '2', user: 'user2', file_name: 'image2.png', file_content: 'ZGF0YTI=', mime_type: 'image/png' } + ], + total: 2, + }); }); - - it('should return null if not images is not found', async () => { - mockImagesCollection.find.mockReturnValue({ sort: jest.fn().mockReturnValue(undefined) }); - const result = await images.getAll(1, 10); - expect(db.connect).toHaveBeenCalled(); - expect(db.getConnection).toHaveBeenCalled(); - expect(dbConn.collection).toHaveBeenCalledWith('images'); - expect(mockImagesCollection.find).toHaveBeenCalledWith({}); - expect(result).toEqual(null); + + it('should return an empty array if no images are found', async () => { + mockImagesCollection.countDocuments.mockResolvedValue(0); + mockFindCursor.toArray.mockResolvedValue([]); + + const result = await images.getImages(1, 10); + + expect(result).toEqual({ images: [], total: 0 }); }); }); }); \ No newline at end of file From de5f8fad6cb58074a78b921b4196b3f3d5220eb1 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Tue, 11 Mar 2025 19:50:28 -0400 Subject: [PATCH 13/25] FIX - url DEV et PROD - images par prof --- client/src/Types/Images.tsx | 6 ++ .../components/ImageGallery/ImageGallery.tsx | 4 +- client/src/constants.tsx | 1 + client/src/services/ApiService.tsx | 56 +++++++++++++++++-- docker-compose.yaml | 1 + server/controllers/images.js | 25 +++++++-- server/models/images.js | 33 ++++++++++- server/routers/images.js | 1 + 8 files changed, 115 insertions(+), 12 deletions(-) diff --git a/client/src/Types/Images.tsx b/client/src/Types/Images.tsx index 84e1e5a..8cfe170 100644 --- a/client/src/Types/Images.tsx +++ b/client/src/Types/Images.tsx @@ -9,3 +9,9 @@ export interface ImagesResponse { images: Images[]; total: number; } + +export interface ImagesParams { + page: number; + limit: number; + uid?: string; +} \ No newline at end of file diff --git a/client/src/components/ImageGallery/ImageGallery.tsx b/client/src/components/ImageGallery/ImageGallery.tsx index 53b181b..7184cda 100644 --- a/client/src/components/ImageGallery/ImageGallery.tsx +++ b/client/src/components/ImageGallery/ImageGallery.tsx @@ -20,6 +20,7 @@ import CloseIcon from "@mui/icons-material/Close"; import EditIcon from "@mui/icons-material/Edit"; import { Images } from "../../Types/Images"; import ApiService from '../../services/ApiService'; +import { ENV_VARIABLES } from '../constants'; type Props = { galleryOpen: boolean; @@ -52,7 +53,8 @@ const ImageDialog: React.FC = ({ galleryOpen, admin, setDialogOpen, setIm }; const onCopy = (id: string) => { - const escLink = 'http://localhost:4400/api/image/get/'+id; + const escLink = `${ENV_VARIABLES.IMG_URL}/api/image/get/${id}`; + console.log(escLink); setCopiedId(id); setImageLinks(prevLinks => [...prevLinks, escLink]); }; diff --git a/client/src/constants.tsx b/client/src/constants.tsx index ad5b80b..fcdd278 100644 --- a/client/src/constants.tsx +++ b/client/src/constants.tsx @@ -2,6 +2,7 @@ const ENV_VARIABLES = { MODE: process.env.MODE || "production", VITE_BACKEND_URL: process.env.VITE_BACKEND_URL || "", + IMG_URL: process.env.MODE == "development" ? process.env.VITE_BACKEND_URL : process.env.IMG_URL, BACKEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.BACKEND_PORT}`:''}` : process.env.VITE_BACKEND_URL || '', FRONTEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.PORT}`:''}` : '' }; diff --git a/client/src/services/ApiService.tsx b/client/src/services/ApiService.tsx index fe573eb..0984652 100644 --- a/client/src/services/ApiService.tsx +++ b/client/src/services/ApiService.tsx @@ -3,7 +3,7 @@ import { jwtDecode } from 'jwt-decode'; import { ENV_VARIABLES } from '../constants'; import { FolderType } from 'src/Types/FolderType'; -import { ImagesResponse } from '../Types/Images'; +import { ImagesResponse, ImagesParams } from '../Types/Images'; import { QuizType } from 'src/Types/QuizType'; import { RoomType } from 'src/Types/RoomType'; @@ -143,6 +143,21 @@ class ApiService { return object.username; } + public getUserID(): string { + const objectStr = localStorage.getItem("jwt"); + + if (!objectStr) { + return ""; + } + + const jsonObj = JSON.parse(objectStr); + if (!jsonObj.userId) { + return ""; + } + + return jsonObj.userId; + } + // Route to know if rooms need authentication to join public async getRoomsRequireAuth(): Promise { const url: string = this.constructRequestUrl(`/auth/getRoomsRequireAuth`); @@ -1169,20 +1184,51 @@ public async login(email: string, password: string): Promise { } } - public async getImages(page: number, limit: number): Promise { try { const url: string = this.constructRequestUrl(`/image/getImages`); const headers = this.constructRequestHeaders(); - const params = { page: page, limit: limit}; + let params : ImagesParams = { page: page, limit: limit }; const result: AxiosResponse = await axios.get(url, { params: params, headers: headers }); if (result.status !== 200) { - throw new Error(`L'enregistrement a échoué. Status: ${result.status}`); + throw new Error(`L'affichage des images a échoué. Status: ${result.status}`); + } + const images = result.data; + + return images; + + } catch (error) { + console.log("Error details: ", error); + + if (axios.isAxiosError(error)) { + const err = error as AxiosError; + const data = err.response?.data as { error: string } | undefined; + const msg = data?.error || 'Erreur serveur inconnue lors de la requête.'; + throw new Error(`L'enregistrement a échoué. Status: ${msg}`); } - console.log(result.data); + throw new Error(`ERROR : Une erreur inattendue s'est produite.`); + } + } + + public async getUserImages(page: number, limit: number): Promise { + try { + const url: string = this.constructRequestUrl(`/image/getUserImages`); + const headers = this.constructRequestHeaders(); + let params : ImagesParams = { page: page, limit: limit }; + + const uid = this.getUserID(); + if(uid !== ''){ + params.uid = uid; + } + + const result: AxiosResponse = await axios.get(url, { params: params, headers: headers }); + + if (result.status !== 200) { + throw new Error(`L'affichage des images de l'utilisateur a échoué. Status: ${result.status}`); + } const images = result.data; return images; diff --git a/docker-compose.yaml b/docker-compose.yaml index 539c800..8db680e 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,6 +10,7 @@ services: - VITE_BACKEND_URL= # Define empty VITE_BACKEND_SOCKET_URL so it will default to window.location.host - VITE_BACKEND_SOCKET_URL= + - IMG_URL: https://evalsa.etsmtl.ca ports: - "5173:5173" restart: always diff --git a/server/controllers/images.js b/server/controllers/images.js index c5675d4..e0ac12c 100644 --- a/server/controllers/images.js +++ b/server/controllers/images.js @@ -54,15 +54,32 @@ class ImagesController { try { const page = parseInt(req.query.page) || 1; const limit = parseInt(req.query.limit) || 5; + const images = await this.images.getImages(page, limit); - const imagesBit = await this.images.getImages(page, limit); - - if (!imagesBit || imagesBit.length === 0) { + if (!images || images.length === 0) { throw new AppError(IMAGE_NOT_FOUND); } res.setHeader('Content-Type', 'application/json'); - return res.status(200).json(imagesBit); + return res.status(200).json(images); + } catch (error) { + return next(error); + } + }; + + getUserImages = async (req, res, next) => { + try { + const page = parseInt(req.query.page) || 1; + const limit = parseInt(req.query.limit) || 5; + const uid = req.query.uid; + const images = await this.images.getUserImages(page, limit, uid); + + if (!images || images.length === 0) { + throw new AppError(IMAGE_NOT_FOUND); + } + + res.setHeader('Content-Type', 'application/json'); + return res.status(200).json(images); } catch (error) { return next(error); } diff --git a/server/models/images.js b/server/models/images.js index 13d6ad0..036ce1e 100644 --- a/server/models/images.js +++ b/server/models/images.js @@ -49,11 +49,40 @@ class Images { const imagesCollection = conn.collection('images'); - const total = await imagesCollection.countDocuments(); // Efficient total count + const total = await imagesCollection.countDocuments(); if (!total || total === 0) return { images: [], total }; const result = await imagesCollection.find({}) - .sort({ created_at: 1 }) // Ensure 'created_at' is indexed + .sort({ created_at: 1 }) + .skip((page - 1) * limit) + .limit(limit) + .toArray(); + + const objImages = result.map(image => ({ + id: image._id, + user: image.userId, + file_name: image.file_name, + file_content: image.file_content.toString('base64'), + mime_type: image.mime_type + })); + + let respObj = { + images: objImages, + total: total + } + + return respObj; + } + + async getUserImages(page, limit, uid) { + await this.db.connect() + const conn = this.db.getConnection(); + const imagesCollection = conn.collection('images'); + const total = await imagesCollection.countDocuments({ userId: uid }); + if (!total || total === 0) return { images: [], total }; + + const result = await imagesCollection.find({ userId: uid }) + .sort({ created_at: 1 }) .skip((page - 1) * limit) .limit(limit) .toArray(); diff --git a/server/routers/images.js b/server/routers/images.js index 626e6e8..40aa481 100644 --- a/server/routers/images.js +++ b/server/routers/images.js @@ -13,5 +13,6 @@ const upload = multer({ storage: storage }); router.post("/upload", jwt.authenticate, upload.single('image'), asyncHandler(images.upload)); router.get("/get/:id", asyncHandler(images.get)); router.get("/getImages", asyncHandler(images.getImages)); +router.get("/getUserImages", asyncHandler(images.getUserImages)); module.exports = router; From 9ff28827611e858839c06ccbe6b96250e31343d5 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Tue, 11 Mar 2025 19:53:34 -0400 Subject: [PATCH 14/25] updated path --- client/src/components/ImageGallery/ImageGallery.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/components/ImageGallery/ImageGallery.tsx b/client/src/components/ImageGallery/ImageGallery.tsx index 7184cda..069a846 100644 --- a/client/src/components/ImageGallery/ImageGallery.tsx +++ b/client/src/components/ImageGallery/ImageGallery.tsx @@ -20,7 +20,7 @@ import CloseIcon from "@mui/icons-material/Close"; import EditIcon from "@mui/icons-material/Edit"; import { Images } from "../../Types/Images"; import ApiService from '../../services/ApiService'; -import { ENV_VARIABLES } from '../constants'; +import { ENV_VARIABLES } from '../../constants'; type Props = { galleryOpen: boolean; From f4bcfd27b95552e6d6743b92a442e1ef3547014b Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Tue, 11 Mar 2025 23:15:59 -0400 Subject: [PATCH 15/25] ajout test getuserimages --- server/__tests__/image.test.js | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/server/__tests__/image.test.js b/server/__tests__/image.test.js index 1a56431..671ebe8 100644 --- a/server/__tests__/image.test.js +++ b/server/__tests__/image.test.js @@ -194,4 +194,53 @@ describe('Images', () => { expect(result).toEqual({ images: [], total: 0 }); }); }); + + describe('getUserImages', () => { + it('should return empty images array when no images exist', async () => { + mockImagesCollection.countDocuments.mockResolvedValue(0); + + const result = await images.getUserImages(1, 10, 'user123'); + + expect(result).toEqual({ images: [], total: 0 }); + expect(db.connect).toHaveBeenCalled(); + expect(mockImagesCollection.countDocuments).toHaveBeenCalledWith({ userId: 'user123' }); + }); + + it('should return images when they exist', async () => { + const mockImages = [ + { + _id: 'img1', + userId: 'user123', + file_name: 'image1.png', + file_content: Buffer.from('testdata'), + mime_type: 'image/png' + } + ]; + + mockImagesCollection.countDocuments.mockResolvedValue(1); + mockImagesCollection.find.mockReturnValue({ + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue(mockImages) + }); + + const result = await images.getUserImages(1, 10, 'user123'); + + expect(result).toEqual({ + images: [ + { + id: 'img1', + user: 'user123', + file_name: 'image1.png', + file_content: Buffer.from('testdata').toString('base64'), + mime_type: 'image/png' + } + ], + total: 1 + }); + expect(db.connect).toHaveBeenCalled(); + expect(mockImagesCollection.countDocuments).toHaveBeenCalledWith({ userId: 'user123' }); + }); + }); }); \ No newline at end of file From b7ec35c0c96465058a68fe5074765a59bd4f1b36 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Thu, 13 Mar 2025 18:02:26 -0400 Subject: [PATCH 16/25] FIX - ajustement delete image et tests --- .../ImageGallery/ImageGallery.test.tsx | 68 ++++--- .../components/ImageGallery/ImageGallery.tsx | 179 ++++++++++-------- client/src/services/ApiService.tsx | 30 +++ server/__tests__/image.test.js | 61 +++++- server/controllers/images.js | 17 ++ server/models/images.js | 18 ++ server/routers/images.js | 1 + 7 files changed, 269 insertions(+), 105 deletions(-) diff --git a/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx b/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx index 9604b35..f1da956 100644 --- a/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx +++ b/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx @@ -32,7 +32,6 @@ describe("ImageDialog Component", () => { render( @@ -50,7 +49,6 @@ describe("ImageDialog Component", () => { render( @@ -68,7 +66,6 @@ describe("ImageDialog Component", () => { render( @@ -85,32 +82,11 @@ describe("ImageDialog Component", () => { expect(screen.getByText("Copié!")).toBeInTheDocument(); }); - test("shows edit field when admin clicks edit button", async () => { - await act(async () => { - render( - - ); - }); - - await waitFor(() => expect(ApiService.getImages).toHaveBeenCalled()); - - const editButton = screen.getByTestId("edit-button-1"); - fireEvent.click(editButton); - - expect(screen.getByDisplayValue("image1.jpg")).toBeInTheDocument(); - }); - test("navigates to next and previous page", async () => { await act(async () => { render( @@ -127,4 +103,48 @@ describe("ImageDialog Component", () => { await waitFor(() => expect(ApiService.getImages).toHaveBeenCalledWith(1, 3)); }); + + test("deletes an image successfully", async () => { + jest.spyOn(ApiService, "deleteImage").mockResolvedValue(true); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => expect(ApiService.getImages).toHaveBeenCalled()); + + fireEvent.click(screen.getByTestId("delete-button-1")); + + await waitFor(() => expect(ApiService.deleteImage).toHaveBeenCalledWith("1")); + + expect(screen.queryByTestId("delete-button-1")).not.toBeInTheDocument(); + }); + + test("handles failed delete when image is linked", async () => { + jest.spyOn(ApiService, "deleteImage").mockResolvedValue(false); + + await act(async () => { + render( + + ); + }); + + await waitFor(() => expect(ApiService.getImages).toHaveBeenCalled()); + + fireEvent.click(screen.getByTestId("delete-button-1")); + + await waitFor(() => expect(ApiService.deleteImage).toHaveBeenCalledWith("1")); + + expect(screen.getByText("Confirmer la suppression")).toBeInTheDocument(); + }); }); \ No newline at end of file diff --git a/client/src/components/ImageGallery/ImageGallery.tsx b/client/src/components/ImageGallery/ImageGallery.tsx index 069a846..60dfc14 100644 --- a/client/src/components/ImageGallery/ImageGallery.tsx +++ b/client/src/components/ImageGallery/ImageGallery.tsx @@ -12,31 +12,30 @@ import { TableRow, IconButton, Paper, - TextField, - Box + Box, + CircularProgress } from "@mui/material"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import CloseIcon from "@mui/icons-material/Close"; -import EditIcon from "@mui/icons-material/Edit"; +import DeleteIcon from "@mui/icons-material/Delete"; import { Images } from "../../Types/Images"; import ApiService from '../../services/ApiService'; import { ENV_VARIABLES } from '../../constants'; type Props = { galleryOpen: boolean; - admin: boolean; setDialogOpen: React.Dispatch>; setImageLinks: React.Dispatch>; -} - -const ImageDialog: React.FC = ({ galleryOpen, admin, setDialogOpen, setImageLinks }) => { +}; +const ImageDialog: React.FC = ({ galleryOpen, setDialogOpen, setImageLinks }) => { const [copiedId, setCopiedId] = useState(null); - const [editingId, setEditingId] = useState(null); const [images, setImages] = useState([]); const [totalImg, setTotalImg] = useState(0); const [imgPage, setImgPage] = useState(1); const [imgLimit] = useState(3); + const [loading, setLoading] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState<{ id: string | null; linked: boolean }>({ id: null, linked: false }); const fetchImages = async (page: number, limit: number) => { const data = await ApiService.getImages(page, limit); @@ -46,37 +45,50 @@ const ImageDialog: React.FC = ({ galleryOpen, admin, setDialogOpen, setIm useEffect(() => { fetchImages(imgPage, imgLimit); - }, [imgPage]); // Re-fetch images when page changes - - const handleEditClick = (id: string) => { - setEditingId(id === editingId ? null : id); - }; + }, [imgPage]); const onCopy = (id: string) => { const escLink = `${ENV_VARIABLES.IMG_URL}/api/image/get/${id}`; - console.log(escLink); setCopiedId(id); setImageLinks(prevLinks => [...prevLinks, escLink]); }; - const handleNextPage = () => { - if ((imgPage * imgLimit) < totalImg) { - setImgPage(prev => prev + 1); - } + const handleDelete = async (id: string) => { + setLoading(true); + const isDeleted = await ApiService.deleteImage(id); + setLoading(false); + if (!isDeleted) { + setDeleteConfirm({ id, linked: true }); + } else { + setImages(images.filter(image => image.id !== id)); + setDeleteConfirm({ id: null, linked: false }); + } }; - + + const confirmDelete = async () => { + if (deleteConfirm.id) { + setLoading(true); + await ApiService.deleteImage(deleteConfirm.id); + setImages(images.filter(image => image.id !== deleteConfirm.id)); + setDeleteConfirm({ id: null, linked: false }); + setLoading(false); + } + }; + + const handleNextPage = () => { + if ((imgPage * imgLimit) < totalImg) { + setImgPage(prev => prev + 1); + } + }; + const handlePrevPage = () => { - if (imgPage > 1) { - setImgPage(prev => prev - 1); - } + if (imgPage > 1) { + setImgPage(prev => prev - 1); + } }; return ( - setDialogOpen(false)} - maxWidth="xl" // 'md' stands for medium size - > + setDialogOpen(false)} maxWidth="xl"> Images disponibles = ({ galleryOpen, admin, setDialogOpen, setIm - - - - {images.map((obj: Images) => ( - - - {`Image - - - {admin && editingId === obj.id ? ( - - ) : ( - obj.file_name - )} - - - {obj.id} - onCopy(obj.id)} size="small" data-testid={`copy-button-${obj.id}`}> - - - {admin && ( - <> - handleEditClick(obj.id)} size="small" color="primary" data-testid={`edit-button-${obj.id}`}> - - - - )} - {copiedId === obj.id && Copié!} - - - ))} - -
-
-
- - - - + {loading ? ( + + - + ) : ( + + + + {images.map((obj: Images) => ( + + + {`Image + + {obj.file_name} + + {obj.id} + onCopy(obj.id)} size="small" data-testid={`copy-button-${obj.id}`}> + + + handleDelete(obj.id)} size="small" color="secondary" data-testid={`delete-button-${obj.id}`}> + + + {copiedId === obj.id && Copié!} + + + ))} + +
+
+ )} + + {deleteConfirm.linked && ( + setDeleteConfirm({ id: null, linked: false })}> + Confirmer la suppression + + Cette image est liée à d'autres objets. Êtes-vous sûr de vouloir la supprimer ? + + + + + + + )} + + + + + +
); }; diff --git a/client/src/services/ApiService.tsx b/client/src/services/ApiService.tsx index 0984652..b3b73ed 100644 --- a/client/src/services/ApiService.tsx +++ b/client/src/services/ApiService.tsx @@ -1247,6 +1247,36 @@ public async login(email: string, password: string): Promise { } } + public async deleteImage(imgId: string): Promise { + try { + const url: string = this.constructRequestUrl(`/image/delete`); + const headers = this.constructRequestHeaders(); + const uid = this.getUserID(); + let params = { uid: uid, imgId: imgId }; + + const result: AxiosResponse = await axios.delete(url, { params: params, headers: headers }); + + if (result.status !== 200) { + throw new Error(`La suppression de l'image a échoué. Status: ${result.status}`); + } + const deleted = result.data.delete; + + return deleted; + + } catch (error) { + console.log("Error details: ", error); + + if (axios.isAxiosError(error)) { + const err = error as AxiosError; + const data = err.response?.data as { error: string } | undefined; + const msg = data?.error || 'Erreur serveur inconnue lors de la requête.'; + throw new Error(`L'enregistrement a échoué. Status: ${msg}`); + } + + throw new Error(`ERROR : Une erreur inattendue s'est produite.`); + } + } + } const apiService = new ApiService(); diff --git a/server/__tests__/image.test.js b/server/__tests__/image.test.js index 671ebe8..85b49fd 100644 --- a/server/__tests__/image.test.js +++ b/server/__tests__/image.test.js @@ -88,7 +88,8 @@ describe('Images', () => { insertOne: jest.fn().mockResolvedValue({ insertedId: 'image123' }), findOne: jest.fn(), find: jest.fn().mockReturnValue(mockFindCursor), - countDocuments: jest.fn() + countDocuments: jest.fn(), + deleteOne: jest.fn(), }; dbConn = { @@ -243,4 +244,62 @@ describe('Images', () => { expect(mockImagesCollection.countDocuments).toHaveBeenCalledWith({ userId: 'user123' }); }); }); + + describe('delete', () => { + + it('should delete the image when it exists', async () => { + const uid = 'user123'; + const imgId = 'img123'; + + // Simulate the image being found in the collection + mockImagesCollection.find.mockResolvedValue([{ _id: imgId }]); + mockImagesCollection.deleteOne.mockResolvedValue({ deletedCount: 1 }); + + const result = await images.delete(uid, imgId); + + expect(db.connect).toHaveBeenCalled(); + expect(db.getConnection).toHaveBeenCalled(); + expect(mockImagesCollection.find).toHaveBeenCalledWith({ + userId: uid, + content: { $regex: new RegExp(`/api/image/get/${imgId}`) }, + }); + expect(mockImagesCollection.deleteOne).toHaveBeenCalledWith({ _id: imgId }); + expect(result).toEqual({ deleted: true }); + }); + + it('should not delete the image when it does not exist', async () => { + const uid = 'user123'; + const imgId = 'img123'; + + // Simulate the image not being found in the collection + mockImagesCollection.find.mockResolvedValue([]); + + const result = await images.delete(uid, imgId); + + expect(db.connect).toHaveBeenCalled(); + expect(mockImagesCollection.find).toHaveBeenCalledWith({ + userId: uid, + content: { $regex: new RegExp(`/api/image/get/${imgId}`) }, + }); + expect(mockImagesCollection.deleteOne).toHaveBeenCalled(); + expect(result).toEqual({ deleted: false }); + }); + + it('should return false if the delete operation fails', async () => { + const uid = 'user123'; + const imgId = 'img123'; + + // Simulate the image being found, but the delete operation failing + mockImagesCollection.find.mockResolvedValue([{ _id: imgId }]); + + const result = await images.delete(uid, imgId); + + expect(db.connect).toHaveBeenCalled(); + expect(mockImagesCollection.find).toHaveBeenCalledWith({ + userId: uid, + content: { $regex: new RegExp(`/api/image/get/${imgId}`) }, + }); + expect(result).toEqual({ deleted: false }); + }); + }); }); \ No newline at end of file diff --git a/server/controllers/images.js b/server/controllers/images.js index e0ac12c..7de02ad 100644 --- a/server/controllers/images.js +++ b/server/controllers/images.js @@ -85,6 +85,23 @@ class ImagesController { } }; + delete = async (req, res, next) => { + try { + const uid = req.query.uid; + const imgId = req.query.imgId; + + if (!uid || !imgId) { + throw new AppError(MISSING_REQUIRED_PARAMETER); + } + const images = await this.images.delete(uid, imgId); + + res.setHeader('Content-Type', 'application/json'); + return res.status(200).json(images); + } catch (error) { + return next(error); + } + }; + } module.exports = ImagesController; diff --git a/server/models/images.js b/server/models/images.js index 036ce1e..1dc4912 100644 --- a/server/models/images.js +++ b/server/models/images.js @@ -102,6 +102,24 @@ class Images { return respObj; } + + async delete(uid, imgId) { + let resp = false; + await this.db.connect() + const conn = this.db.getConnection(); + const imgsColl = conn.collection('files'); + const rgxImg = new RegExp(`/api/image/get/${imgId}`); + + const result = await imgsColl.find({ userId: uid, content: { $regex: rgxImg }}); + + if(result){ + const isDeleted = await imgsColl.deleteOne({ _id: imgId }); + if(isDeleted){ + resp = true; + } + } + return { deleted: resp }; + } } module.exports = Images; diff --git a/server/routers/images.js b/server/routers/images.js index 40aa481..2be24d2 100644 --- a/server/routers/images.js +++ b/server/routers/images.js @@ -14,5 +14,6 @@ router.post("/upload", jwt.authenticate, upload.single('image'), asyncHandler(im router.get("/get/:id", asyncHandler(images.get)); router.get("/getImages", asyncHandler(images.getImages)); router.get("/getUserImages", asyncHandler(images.getUserImages)); +router.get("/delete", asyncHandler(images.delete)); module.exports = router; From b7a7962435f059393fefcc6c4a4ce37edca227ac Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Thu, 13 Mar 2025 18:06:24 -0400 Subject: [PATCH 17/25] FIX admin param error --- client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx index 6876150..6ce512d 100644 --- a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx +++ b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx @@ -302,7 +302,6 @@ const QuizForm: React.FC = () => { From 1c16e06319d909a2eb5c6d8576f0f1d6d1bc9efe Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Thu, 13 Mar 2025 21:48:15 -0400 Subject: [PATCH 18/25] FIX - erreur infinite loading --- client/src/components/ImageGallery/ImageGallery.tsx | 2 +- client/src/services/ApiService.tsx | 9 +++++---- server/routers/images.js | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/client/src/components/ImageGallery/ImageGallery.tsx b/client/src/components/ImageGallery/ImageGallery.tsx index 60dfc14..85d80ed 100644 --- a/client/src/components/ImageGallery/ImageGallery.tsx +++ b/client/src/components/ImageGallery/ImageGallery.tsx @@ -124,7 +124,7 @@ const ImageDialog: React.FC = ({ galleryOpen, setDialogOpen, setImageLink onCopy(obj.id)} size="small" data-testid={`copy-button-${obj.id}`}> - handleDelete(obj.id)} size="small" color="secondary" data-testid={`delete-button-${obj.id}`}> + handleDelete(obj.id)} size="small" color="primary" data-testid={`delete-button-${obj.id}`}> {copiedId === obj.id && Copié!} diff --git a/client/src/services/ApiService.tsx b/client/src/services/ApiService.tsx index b3b73ed..acf4066 100644 --- a/client/src/services/ApiService.tsx +++ b/client/src/services/ApiService.tsx @@ -144,13 +144,14 @@ class ApiService { } public getUserID(): string { - const objectStr = localStorage.getItem("jwt"); + const token = localStorage.getItem("jwt"); - if (!objectStr) { + if (!token) { return ""; } - const jsonObj = JSON.parse(objectStr); + const jsonObj = jwtDecode(token) as { userId: string }; + if (!jsonObj.userId) { return ""; } @@ -1259,8 +1260,8 @@ public async login(email: string, password: string): Promise { if (result.status !== 200) { throw new Error(`La suppression de l'image a échoué. Status: ${result.status}`); } - const deleted = result.data.delete; + const deleted = result.data.deleted; return deleted; } catch (error) { diff --git a/server/routers/images.js b/server/routers/images.js index 2be24d2..f2d601a 100644 --- a/server/routers/images.js +++ b/server/routers/images.js @@ -14,6 +14,6 @@ router.post("/upload", jwt.authenticate, upload.single('image'), asyncHandler(im router.get("/get/:id", asyncHandler(images.get)); router.get("/getImages", asyncHandler(images.getImages)); router.get("/getUserImages", asyncHandler(images.getUserImages)); -router.get("/delete", asyncHandler(images.delete)); +router.delete("/delete", asyncHandler(images.delete)); module.exports = router; From 92699f9a837d5bce6c57f63fd6ecda25c3502a03 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Thu, 13 Mar 2025 22:44:43 -0400 Subject: [PATCH 19/25] FIX objectID et mauvaise collection --- server/models/images.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/models/images.js b/server/models/images.js index 1dc4912..16e52de 100644 --- a/server/models/images.js +++ b/server/models/images.js @@ -107,13 +107,13 @@ class Images { let resp = false; await this.db.connect() const conn = this.db.getConnection(); - const imgsColl = conn.collection('files'); + const quizColl = conn.collection('files'); const rgxImg = new RegExp(`/api/image/get/${imgId}`); - const result = await imgsColl.find({ userId: uid, content: { $regex: rgxImg }}); - - if(result){ - const isDeleted = await imgsColl.deleteOne({ _id: imgId }); + const result = await quizColl.find({ userId: uid, content: { $regex: rgxImg }}).toArray(); + if(!result || result.length < 1){ + const imgsColl = conn.collection('images'); + const isDeleted = await imgsColl.deleteOne({ _id: ObjectId.createFromHexString(imgId) }); if(isDeleted){ resp = true; } From e2376dd8c34d318b08566e732f29c5e5f4f813b3 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Fri, 14 Mar 2025 00:45:59 -0400 Subject: [PATCH 20/25] FIX undefined IMG_URL --- client/src/constants.tsx | 2 +- docker-compose-local.yaml | 4 +++- docker-compose.yaml | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/client/src/constants.tsx b/client/src/constants.tsx index fcdd278..4f6be91 100644 --- a/client/src/constants.tsx +++ b/client/src/constants.tsx @@ -2,7 +2,7 @@ const ENV_VARIABLES = { MODE: process.env.MODE || "production", VITE_BACKEND_URL: process.env.VITE_BACKEND_URL || "", - IMG_URL: process.env.MODE == "development" ? process.env.VITE_BACKEND_URL : process.env.IMG_URL, + IMG_URL: import.meta.env.VITE_IMG_URL || "http://localhost", BACKEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.BACKEND_PORT}`:''}` : process.env.VITE_BACKEND_URL || '', FRONTEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.PORT}`:''}` : '' }; diff --git a/docker-compose-local.yaml b/docker-compose-local.yaml index 0d8d61a..b77c340 100644 --- a/docker-compose-local.yaml +++ b/docker-compose-local.yaml @@ -7,6 +7,8 @@ services: context: ./client dockerfile: Dockerfile container_name: frontend + environment: + VITE_IMG_URL: http://localhost ports: - "5173:5173" restart: always @@ -54,7 +56,7 @@ services: image: mongo container_name: mongo ports: - - "27017:27017" + - "27019:27017" tty: true volumes: - mongodb_data:/data/db diff --git a/docker-compose.yaml b/docker-compose.yaml index 8db680e..d133982 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -10,7 +10,7 @@ services: - VITE_BACKEND_URL= # Define empty VITE_BACKEND_SOCKET_URL so it will default to window.location.host - VITE_BACKEND_SOCKET_URL= - - IMG_URL: https://evalsa.etsmtl.ca + - VITE_IMG_URL=https://evalsa.etsmtl.ca ports: - "5173:5173" restart: always From ab9636c8db1e0f8a7e337ec157b7f7274c00f797 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Fri, 14 Mar 2025 00:53:58 -0400 Subject: [PATCH 21/25] test issues --- client/src/constants.tsx | 2 +- client/tsconfig.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/client/src/constants.tsx b/client/src/constants.tsx index 4f6be91..1e6dd3e 100644 --- a/client/src/constants.tsx +++ b/client/src/constants.tsx @@ -2,7 +2,7 @@ const ENV_VARIABLES = { MODE: process.env.MODE || "production", VITE_BACKEND_URL: process.env.VITE_BACKEND_URL || "", - IMG_URL: import.meta.env.VITE_IMG_URL || "http://localhost", + IMG_URL: typeof import.meta !== "undefined" ? import.meta.env.VITE_IMG_URL || "http://localhost" : process.env.VITE_IMG_URL || "http://localhost", BACKEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.BACKEND_PORT}`:''}` : process.env.VITE_BACKEND_URL || '', FRONTEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.PORT}`:''}` : '' }; diff --git a/client/tsconfig.json b/client/tsconfig.json index 5115a44..058fb19 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -27,7 +27,8 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "esModuleInterop": true + "esModuleInterop": true, + "types": ["vite/client"] // ✅ Add this }, // "exclude": [ // // "src/components/GiftTemplate/**/*", From d9480e2d14246c4461cffa1442dff0094518de90 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Fri, 14 Mar 2025 00:54:14 -0400 Subject: [PATCH 22/25] fichier manquant --- client/tsconfig.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/tsconfig.json b/client/tsconfig.json index 058fb19..d99f011 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -28,7 +28,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, - "types": ["vite/client"] // ✅ Add this + "types": ["vite/client"] }, // "exclude": [ // // "src/components/GiftTemplate/**/*", From 4e0d5d778da58b8452b4665bba37d4fa34002805 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Fri, 14 Mar 2025 01:56:53 -0400 Subject: [PATCH 23/25] fixed server tests --- client/src/constants.tsx | 2 +- client/tsconfig.json | 3 +- server/__tests__/image.test.js | 160 +++++++++++++++++++++------------ 3 files changed, 107 insertions(+), 58 deletions(-) diff --git a/client/src/constants.tsx b/client/src/constants.tsx index 1e6dd3e..fcdd278 100644 --- a/client/src/constants.tsx +++ b/client/src/constants.tsx @@ -2,7 +2,7 @@ const ENV_VARIABLES = { MODE: process.env.MODE || "production", VITE_BACKEND_URL: process.env.VITE_BACKEND_URL || "", - IMG_URL: typeof import.meta !== "undefined" ? import.meta.env.VITE_IMG_URL || "http://localhost" : process.env.VITE_IMG_URL || "http://localhost", + IMG_URL: process.env.MODE == "development" ? process.env.VITE_BACKEND_URL : process.env.IMG_URL, BACKEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.BACKEND_PORT}`:''}` : process.env.VITE_BACKEND_URL || '', FRONTEND_URL: process.env.SITE_URL != undefined ? `${process.env.SITE_URL}${process.env.USE_PORTS ? `:${process.env.PORT}`:''}` : '' }; diff --git a/client/tsconfig.json b/client/tsconfig.json index d99f011..5115a44 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -27,8 +27,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "esModuleInterop": true, - "types": ["vite/client"] + "esModuleInterop": true }, // "exclude": [ // // "src/components/GiftTemplate/**/*", diff --git a/server/__tests__/image.test.js b/server/__tests__/image.test.js index 85b49fd..7ce4ad1 100644 --- a/server/__tests__/image.test.js +++ b/server/__tests__/image.test.js @@ -68,6 +68,17 @@ describe.skip("GET /get", () => { }) +jest.mock('mongodb', () => { + const originalModule = jest.requireActual('mongodb'); + return { + ...originalModule, + ObjectId: { + ...originalModule.ObjectId, + createFromHexString: jest.fn().mockReturnValue('507f191e810c19729de860ea'), // Return a valid 24-character ObjectId string + }, + }; +}); + describe('Images', () => { let db; let images; @@ -76,24 +87,41 @@ describe('Images', () => { let mockFindCursor; beforeEach(() => { + + const mockImagesCursor = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + toArray: jest.fn() + }; - mockFindCursor = { - sort: jest.fn().mockReturnThis(), - skip: jest.fn().mockReturnThis(), - limit: jest.fn().mockReturnThis(), - toArray: jest.fn(), - }; + const mockFilesCursor = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + toArray: jest.fn() + }; mockImagesCollection = { insertOne: jest.fn().mockResolvedValue({ insertedId: 'image123' }), findOne: jest.fn(), - find: jest.fn().mockReturnValue(mockFindCursor), + find: jest.fn().mockReturnValue(mockImagesCursor), countDocuments: jest.fn(), - deleteOne: jest.fn(), + deleteOne: jest.fn() }; + mockFilesCollection = { + find: jest.fn().mockReturnValue(mockFilesCursor) + }; + dbConn = { - collection: jest.fn().mockReturnValue(mockImagesCollection) + collection: jest.fn((name) => { + if (name === 'images') { + return mockImagesCollection; + } else if (name === 'files') { + return mockFilesCollection; + } + }) }; db = { @@ -166,8 +194,16 @@ describe('Images', () => { ]; mockImagesCollection.countDocuments.mockResolvedValue(2); - mockFindCursor.toArray.mockResolvedValue(mockImages); + // Create a mock cursor for images collection + const mockFindCursor = { + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue(mockImages), // Return mock images when toArray is called + }; + // Mock the find method to return the mock cursor + mockImagesCollection.find.mockReturnValue(mockFindCursor); const result = await images.getImages(1, 10); expect(db.connect).toHaveBeenCalled(); @@ -188,7 +224,6 @@ describe('Images', () => { it('should return an empty array if no images are found', async () => { mockImagesCollection.countDocuments.mockResolvedValue(0); - mockFindCursor.toArray.mockResolvedValue([]); const result = await images.getImages(1, 10); @@ -244,62 +279,77 @@ describe('Images', () => { expect(mockImagesCollection.countDocuments).toHaveBeenCalledWith({ userId: 'user123' }); }); }); - describe('delete', () => { - - it('should delete the image when it exists', async () => { + it('should not delete the image when it exists in the files collection', async () => { const uid = 'user123'; - const imgId = 'img123'; - - // Simulate the image being found in the collection - mockImagesCollection.find.mockResolvedValue([{ _id: imgId }]); - mockImagesCollection.deleteOne.mockResolvedValue({ deletedCount: 1 }); - + const imgId = '507f191e810c19729de860ea'; // A valid 24-character ObjectId string + + // Mock the files collection cursor to simulate an image found + const mockFilesCursor = { + toArray: jest.fn().mockResolvedValue([{ _id: imgId }]) // Image found + }; + + mockFilesCollection.find.mockReturnValue(mockFilesCursor); + mockImagesCollection.deleteOne.mockResolvedValue({ deletedCount: 0 }); + const result = await images.delete(uid, imgId); - - expect(db.connect).toHaveBeenCalled(); - expect(db.getConnection).toHaveBeenCalled(); - expect(mockImagesCollection.find).toHaveBeenCalledWith({ + + // Ensure the files collection is queried + expect(dbConn.collection).toHaveBeenCalledWith('files'); + expect(mockFilesCollection.find).toHaveBeenCalledWith({ userId: uid, content: { $regex: new RegExp(`/api/image/get/${imgId}`) }, }); - expect(mockImagesCollection.deleteOne).toHaveBeenCalledWith({ _id: imgId }); + + // Ensure the images collection is queried for deletion + expect(dbConn.collection).toHaveBeenCalledWith('files'); + expect(mockImagesCollection.deleteOne).not.toHaveBeenCalledWith({ + _id: ObjectId.createFromHexString(imgId), // Ensure the ObjectId is created correctly + }); + + expect(result).toEqual({ deleted: false }); + }); + + it('should delete the image if not found in the files collection', async () => { + const uid = 'user123'; + const imgId = '507f191e810c19729de860ea'; + + // Mock the files collection cursor to simulate the image not being found + const mockFindCursor = { + toArray: jest.fn().mockResolvedValue([]) // Empty array means image not found + }; + + mockFilesCollection.find.mockReturnValue(mockFindCursor); + mockImagesCollection.deleteOne.mockResolvedValue({ deletedCount: 1 }); + + const result = await images.delete(uid, imgId); + + // Ensure the deleteOne is not called if the image is not found + expect(mockImagesCollection.deleteOne).toHaveBeenCalled(); expect(result).toEqual({ deleted: true }); }); - - it('should not delete the image when it does not exist', async () => { + + it('should return false if the delete operation fails in the images collection', async () => { const uid = 'user123'; - const imgId = 'img123'; - - // Simulate the image not being found in the collection - mockImagesCollection.find.mockResolvedValue([]); - + const imgId = '507f191e810c19729de860ea'; + + // Mock the files collection cursor to simulate the image being found + const mockFindCursor = { + toArray: jest.fn().mockResolvedValue([{ _id: imgId }]) // Image found + }; + + mockFilesCollection.find.mockReturnValue(mockFindCursor); + mockImagesCollection.deleteOne.mockResolvedValue({ deletedCount: 0 }); // Simulate failure + const result = await images.delete(uid, imgId); - - expect(db.connect).toHaveBeenCalled(); - expect(mockImagesCollection.find).toHaveBeenCalledWith({ - userId: uid, - content: { $regex: new RegExp(`/api/image/get/${imgId}`) }, - }); - expect(mockImagesCollection.deleteOne).toHaveBeenCalled(); - expect(result).toEqual({ deleted: false }); - }); - - it('should return false if the delete operation fails', async () => { - const uid = 'user123'; - const imgId = 'img123'; - - // Simulate the image being found, but the delete operation failing - mockImagesCollection.find.mockResolvedValue([{ _id: imgId }]); - - const result = await images.delete(uid, imgId); - - expect(db.connect).toHaveBeenCalled(); - expect(mockImagesCollection.find).toHaveBeenCalledWith({ - userId: uid, - content: { $regex: new RegExp(`/api/image/get/${imgId}`) }, + + // Ensure the images collection deletion is called + expect(mockImagesCollection.deleteOne).not.toHaveBeenCalledWith({ + _id: ObjectId.createFromHexString(imgId), // Ensure the ObjectId is created correctly }); + expect(result).toEqual({ deleted: false }); }); + }); }); \ No newline at end of file From c50cd3e6e7a624df277f1e0c160f64f425d0c8b0 Mon Sep 17 00:00:00 2001 From: Eddi3_As Date: Thu, 20 Mar 2025 21:11:46 -0400 Subject: [PATCH 24/25] =?UTF-8?q?FIX=20-=20Changement=20de=20table=20en=20?= =?UTF-8?q?gallery=20-=20Ajout=20option=20de=20r=C3=A9solution=20-=20Ajout?= =?UTF-8?q?=20warning=20-=20Int=C3=A9gration=20de=20upload=20-=20Ajout=20d?= =?UTF-8?q?e=20snackbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/Types/ImageType.tsx | 17 + .../components/ImageGallery/ImageGallery.tsx | 359 ++++++++++++------ .../ImageGalleryModal/ImageGalleryModal.tsx | 54 +++ .../pages/Teacher/EditorQuiz/EditorQuiz.tsx | 100 +---- 4 files changed, 320 insertions(+), 210 deletions(-) create mode 100644 client/src/Types/ImageType.tsx create mode 100644 client/src/components/ImageGallery/ImageGalleryModal/ImageGalleryModal.tsx diff --git a/client/src/Types/ImageType.tsx b/client/src/Types/ImageType.tsx new file mode 100644 index 0000000..d0a1541 --- /dev/null +++ b/client/src/Types/ImageType.tsx @@ -0,0 +1,17 @@ +export interface ImageType { + id: string; + file_content: string; + file_name: string; + mime_type: string; +} + +export interface ImagesResponse { + images: ImageType[]; + total: number; +} + +export interface ImagesParams { + page: number; + limit: number; + uid?: string; +} \ No newline at end of file diff --git a/client/src/components/ImageGallery/ImageGallery.tsx b/client/src/components/ImageGallery/ImageGallery.tsx index 85d80ed..dfb63d8 100644 --- a/client/src/components/ImageGallery/ImageGallery.tsx +++ b/client/src/components/ImageGallery/ImageGallery.tsx @@ -1,169 +1,280 @@ import React, { useState, useEffect } from "react"; import { + Box, + CircularProgress, + Button, + IconButton, + Card, + CardContent, Dialog, - DialogTitle, DialogContent, DialogActions, - Button, - Table, - TableBody, - TableCell, - TableContainer, - TableRow, - IconButton, - Paper, - Box, - CircularProgress + DialogTitle, + DialogContentText, + Tabs, + Tab, + TextField, Snackbar, + Alert } from "@mui/material"; +import DeleteIcon from "@mui/icons-material/Delete"; import ContentCopyIcon from "@mui/icons-material/ContentCopy"; import CloseIcon from "@mui/icons-material/Close"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { Images } from "../../Types/Images"; -import ApiService from '../../services/ApiService'; -import { ENV_VARIABLES } from '../../constants'; +import { ImageType } from "../../Types/ImageType"; +import ApiService from "../../services/ApiService"; +import { Upload } from "@mui/icons-material"; -type Props = { - galleryOpen: boolean; - setDialogOpen: React.Dispatch>; - setImageLinks: React.Dispatch>; -}; +interface ImagesProps { + handleCopy?: (id: string) => void; +} -const ImageDialog: React.FC = ({ galleryOpen, setDialogOpen, setImageLinks }) => { - const [copiedId, setCopiedId] = useState(null); - const [images, setImages] = useState([]); +const ImageGallery: React.FC = ({ handleCopy }) => { + const [images, setImages] = useState([]); const [totalImg, setTotalImg] = useState(0); const [imgPage, setImgPage] = useState(1); - const [imgLimit] = useState(3); + const [imgLimit] = useState(6); const [loading, setLoading] = useState(false); - const [deleteConfirm, setDeleteConfirm] = useState<{ id: string | null; linked: boolean }>({ id: null, linked: false }); + const [selectedImage, setSelectedImage] = useState(null); + const [openDeleteDialog, setOpenDeleteDialog] = useState(false); + const [imageToDelete, setImageToDelete] = useState(null); + const [tabValue, setTabValue] = useState(0); + const [importedImage, setImportedImage] = useState(null); + const [preview, setPreview] = useState(null); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(""); + const [snackbarSeverity, setSnackbarSeverity] = useState<"success" | "error">("success"); - const fetchImages = async (page: number, limit: number) => { - const data = await ApiService.getImages(page, limit); + const fetchImages = async () => { + setLoading(true); + const data = await ApiService.getImages(imgPage, imgLimit); setImages(data.images); setTotalImg(data.total); + setLoading(false); }; useEffect(() => { - fetchImages(imgPage, imgLimit); + fetchImages(); }, [imgPage]); - const onCopy = (id: string) => { - const escLink = `${ENV_VARIABLES.IMG_URL}/api/image/get/${id}`; - setCopiedId(id); - setImageLinks(prevLinks => [...prevLinks, escLink]); - }; - - const handleDelete = async (id: string) => { - setLoading(true); - const isDeleted = await ApiService.deleteImage(id); - setLoading(false); - if (!isDeleted) { - setDeleteConfirm({ id, linked: true }); - } else { - setImages(images.filter(image => image.id !== id)); - setDeleteConfirm({ id: null, linked: false }); - } - }; - - const confirmDelete = async () => { - if (deleteConfirm.id) { + const handleDelete = async () => { + if (imageToDelete) { setLoading(true); - await ApiService.deleteImage(deleteConfirm.id); - setImages(images.filter(image => image.id !== deleteConfirm.id)); - setDeleteConfirm({ id: null, linked: false }); + const isDeleted = await ApiService.deleteImage(imageToDelete.id); setLoading(false); + + if (isDeleted) { + setImages(images.filter((image) => image.id !== imageToDelete.id)); + setSnackbarMessage("Image supprimée avec succès !"); + setSnackbarSeverity("success"); + } else { + setSnackbarMessage("Erreur lors de la suppression de l'image. Veuillez réessayer."); + setSnackbarSeverity("error"); + } + + setSnackbarOpen(true); + setSelectedImage(null); + setImageToDelete(null); + setOpenDeleteDialog(false); } }; - const handleNextPage = () => { - if ((imgPage * imgLimit) < totalImg) { - setImgPage(prev => prev + 1); + const defaultHandleCopy = (id: string) => { + if (navigator.clipboard) { + navigator.clipboard.writeText(id); } }; - const handlePrevPage = () => { - if (imgPage > 1) { - setImgPage(prev => prev - 1); + const handleCopyFunction = handleCopy || defaultHandleCopy; + + const handleImageUpload = (event: React.ChangeEvent) => { + const file = event.target.files ? event.target.files[0] : null; + setImportedImage(file); + if (file) { + const objectUrl = URL.createObjectURL(file); + setPreview(objectUrl); + } + }; + + const handleSaveImage = async () => { + try { + if (!importedImage) { + setSnackbarMessage("Veuillez d'abord choisir une image à téléverser."); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + return; + } + + const imageUrl = await ApiService.uploadImage(importedImage); + + if (imageUrl.includes("ERROR")) { + setSnackbarMessage("Une erreur est survenue. Veuillez réessayer plus tard."); + setSnackbarSeverity("error"); + setSnackbarOpen(true); + return; + } + fetchImages(); + + setSnackbarMessage("Téléversée avec succès !"); + setSnackbarSeverity("success"); + setSnackbarOpen(true); + + // Reset the input field and preview after successful upload + setImportedImage(null); + setPreview(null); + } catch (error) { + setSnackbarMessage(`Une erreur est survenue.\n${error}\nVeuillez réessayer plus tard.`); + setSnackbarSeverity("error"); + setSnackbarOpen(true); } }; return ( - setDialogOpen(false)} maxWidth="xl"> - - Images disponibles - setDialogOpen(false)} - style={{ position: "absolute", right: 8, top: 8 }} - > - - - - - {loading ? ( - - - - ) : ( - - - - {images.map((obj: Images) => ( - - + + setTabValue(newValue)}> + + + + {tabValue === 0 && ( + <> + {loading ? ( + + + + ) : ( + <> + + {images.map((obj) => ( + setSelectedImage(obj)}> + {`Image - - {obj.file_name} - - {obj.id} - onCopy(obj.id)} size="small" data-testid={`copy-button-${obj.id}`}> - + + + { + e.stopPropagation(); + handleCopyFunction(obj.id); + }} + color="primary" > + + - handleDelete(obj.id)} size="small" color="primary" data-testid={`delete-button-${obj.id}`}> - + + { + e.stopPropagation(); + setImageToDelete(obj); + setOpenDeleteDialog(true); + }} + color="error" > + + - {copiedId === obj.id && Copié!} - - + + ))} - -
-
- )} -
- {deleteConfirm.linked && ( - setDeleteConfirm({ id: null, linked: false })}> - Confirmer la suppression - - Cette image est liée à d'autres objets. Êtes-vous sûr de vouloir la supprimer ? - - - - - - + + + + + + + )} + )} - - - - + {tabValue === 1 && ( + + {/* Image Preview at the top */} + {preview && ( + + Preview + + )} + + + + - -
+ )} + setSelectedImage(null)} maxWidth="md"> + setSelectedImage(null)} sx={{ position: "absolute", right: 8, top: 8, zIndex: 1 }}> + + + + {selectedImage && ( + Enlarged view + )} + + + + {/* Delete Confirmation Dialog */} + setOpenDeleteDialog(false)}> + Supprimer + + Voulez-vous supprimer cette image? + + + + + + + + setSnackbarOpen(false)}> + setSnackbarOpen(false)} severity={snackbarSeverity} sx={{ width: "100%" }}> + {snackbarMessage} + + + ); }; -export default ImageDialog; +export default ImageGallery; diff --git a/client/src/components/ImageGallery/ImageGalleryModal/ImageGalleryModal.tsx b/client/src/components/ImageGallery/ImageGalleryModal/ImageGalleryModal.tsx new file mode 100644 index 0000000..7ea2404 --- /dev/null +++ b/client/src/components/ImageGallery/ImageGalleryModal/ImageGalleryModal.tsx @@ -0,0 +1,54 @@ +import React, { useState } from "react"; +import { + Button, + IconButton, + Dialog, + DialogContent, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; +import ImageGallery from "../ImageGallery"; +import { ImageSearch } from "@mui/icons-material"; + + +interface ImageGalleryModalProps { + handleCopy?: (id: string) => void; +} + + +const ImageGalleryModal: React.FC = ({ handleCopy }) => { + const [open, setOpen] = useState(false); + + const handleOpen = () => setOpen(true); + const handleClose = () => setOpen(false); + + return ( + <> + + + + + + + + + + + + ); +}; + +export default ImageGalleryModal; diff --git a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx index 6ce512d..d3989a3 100644 --- a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx +++ b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx @@ -1,5 +1,5 @@ // EditorQuiz.tsx -import React, { useState, useEffect, useRef, CSSProperties } from 'react'; +import React, { useState, useEffect, CSSProperties } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { FolderType } from '../../../Types/FolderType'; @@ -11,13 +11,13 @@ import GIFTTemplatePreview from 'src/components/GiftTemplate/GIFTTemplatePreview import { QuizType } from '../../../Types/QuizType'; import './editorQuiz.css'; -import { Button, TextField, NativeSelect, Divider, Dialog, DialogTitle, DialogActions, DialogContent } from '@mui/material'; +import { Button, TextField, NativeSelect, Divider } from '@mui/material'; import ReturnButton from 'src/components/ReturnButton/ReturnButton'; -import ImageGallery from 'src/components/ImageGallery/ImageGallery'; +import ImageGalleryModal from 'src/components/ImageGallery/ImageGalleryModal/ImageGalleryModal'; import ApiService from '../../../services/ApiService'; import { escapeForGIFT } from '../../../utils/giftUtils'; -import { Upload, ImageSearch } from '@mui/icons-material'; +import { ENV_VARIABLES } from '../../../constants'; interface EditQuizParams { id: string; @@ -39,9 +39,6 @@ const QuizForm: React.FC = () => { const handleSelectFolder = (event: React.ChangeEvent) => { setSelectedFolder(event.target.value); }; - const fileInputRef = useRef(null); - const [dialogOpen, setDialogOpen] = useState(false); - const [galleryOpen, setGalleryOpen] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false); const scrollToTop = () => { @@ -168,44 +165,16 @@ const QuizForm: React.FC = () => { return
Chargement...
; } - const handleSaveImage = async () => { - try { - const inputElement = document.getElementById('file-input') as HTMLInputElement; - - if (!inputElement?.files || inputElement.files.length === 0) { - setDialogOpen(true); - return; - } - - if (!inputElement.files || inputElement.files.length === 0) { - window.alert("Veuillez d'abord choisir une image à téléverser.") - return; - } - - const imageUrl = await ApiService.uploadImage(inputElement.files[0]); - - // Check for errors - if(imageUrl.indexOf("ERROR") >= 0) { - window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`) - return; - } - - setImageLinks(prevLinks => [...prevLinks, imageUrl]); - - // Reset the file input element - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - } catch (error) { - window.alert(`Une erreur est survenue.\n${error}\nVeuillez réessayer plus tard.`) - - } - }; - const handleCopyToClipboard = async (link: string) => { navigator.clipboard.writeText(link); } + const handleCopyImage = (id: string) => { + const escLink = `${ENV_VARIABLES.IMG_URL}/api/image/get/${id}`; + navigator.clipboard.writeText(id); + setImageLinks(prevLinks => [...prevLinks, escLink]); + } + return (
@@ -260,51 +229,10 @@ const QuizForm: React.FC = () => { onEditorChange={handleUpdatePreview} />
-
- - setDialogOpen(false)} > - Erreur - - Veuillez d'abord choisir une image à téléverser. - - - - - +
+

Mes images :

+
- -

Mes images :

- - - - -
@@ -319,7 +247,7 @@ const QuizForm: React.FC = () => {
    {imageLinks.map((link, index) => { - const imgTag = `![alt_text](${escapeForGIFT(link)} "texte de l'infobulle")`; + const imgTag = `[markdown]![alt_text](${escapeForGIFT(link)} "texte de l'infobulle") {T}`; return (
  • Date: Thu, 20 Mar 2025 23:19:54 -0400 Subject: [PATCH 25/25] Fix - tests --- .../ImageGallery/ImageGallery.test.tsx | 159 ++++-------------- .../components/ImageGallery/ImageGallery.tsx | 8 +- 2 files changed, 37 insertions(+), 130 deletions(-) diff --git a/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx b/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx index f1da956..761029f 100644 --- a/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx +++ b/client/src/__tests__/components/ImageGallery/ImageGallery.test.tsx @@ -1,12 +1,10 @@ -import React from "react"; +import React, { act } from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import ImageDialog from "../../../components/ImageGallery/ImageGallery"; +import ImageGallery from "../../../components/ImageGallery/ImageGallery"; import ApiService from "../../../services/ApiService"; import { Images } from "../../../Types/Images"; -import { act } from "react"; import "@testing-library/jest-dom"; -// Mock ApiService jest.mock("../../../services/ApiService"); const mockImages: Images[] = [ @@ -15,136 +13,43 @@ const mockImages: Images[] = [ { id: "3", file_name: "image3.jpg", mime_type: "image/jpeg", file_content: "mockBase64Content3" }, ]; -describe("ImageDialog Component", () => { - let setDialogOpenMock: jest.Mock; - let setImageLinksMock: jest.Mock; +beforeAll(() => { + Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, + }); +}); +describe("ImageGallery", () => { beforeEach(() => { - jest.clearAllMocks(); - setDialogOpenMock = jest.fn(); - setImageLinksMock = jest.fn(); - jest.spyOn(ApiService, "getImages").mockResolvedValue({ images: mockImages, total: 6 }); + (ApiService.getUserImages as jest.Mock).mockResolvedValue({ images: mockImages, total: 3 }); + (ApiService.deleteImage as jest.Mock).mockResolvedValue(true); + (ApiService.uploadImage as jest.Mock).mockResolvedValue('mockImageUrl'); + + render(); }); - test("renders the dialog when open", async () => { - + it("should render images correctly", async () => { await act(async () => { - render( - - ); - }); - - expect(screen.getByText("Images disponibles")).toBeInTheDocument(); - await waitFor(() => expect(ApiService.getImages).toHaveBeenCalledWith(1, 3)); - expect(screen.getAllByRole("img")).toHaveLength(mockImages.length); - }); - - test("closes the dialog when close button is clicked", async () => { - - await act(async () => { - render( - - ); - }); - - fireEvent.click(screen.getByLabelText("close")); - expect(setDialogOpenMock).toHaveBeenCalledWith(false); - }); - - test("copies the image link when copy button is clicked", async () => { - - //const setImageLinksMock = jest.fn(); - await act(async () => { - render( - - ); - }); - - await act(async () => { - await waitFor(() => expect(screen.getAllByRole("img")).toHaveLength(mockImages.length)); + await screen.findByText("Gallery"); }); + + expect(screen.getByAltText("Image image1.jpg")).toBeInTheDocument(); + expect(screen.getByAltText("Image image2.jpg")).toBeInTheDocument(); + }); + + it("should handle copy action", async () => { + const handleCopyMock = jest.fn(); - // Click the copy button - fireEvent.click(screen.getByTestId("copy-button-1")); - // Check that "Copié!" appears - expect(screen.getByText("Copié!")).toBeInTheDocument(); + render(); + + const copyButtons = await waitFor(() => screen.findAllByTestId(/gallery-tab-copy-/)); + await act(async () => { + fireEvent.click(copyButtons[0]); + }); + + expect(navigator.clipboard.writeText).toHaveBeenCalled(); }); - test("navigates to next and previous page", async () => { - await act(async () => { - render( - - ); - }); - - await waitFor(() => expect(ApiService.getImages).toHaveBeenCalledWith(1, 3)); - - fireEvent.click(screen.getByText("Suivant")); - - await waitFor(() => expect(ApiService.getImages).toHaveBeenCalledWith(2, 3)); - - fireEvent.click(screen.getByText("Précédent")); - - await waitFor(() => expect(ApiService.getImages).toHaveBeenCalledWith(1, 3)); - }); - - test("deletes an image successfully", async () => { - jest.spyOn(ApiService, "deleteImage").mockResolvedValue(true); - - await act(async () => { - render( - - ); - }); - - await waitFor(() => expect(ApiService.getImages).toHaveBeenCalled()); - - fireEvent.click(screen.getByTestId("delete-button-1")); - - await waitFor(() => expect(ApiService.deleteImage).toHaveBeenCalledWith("1")); - - expect(screen.queryByTestId("delete-button-1")).not.toBeInTheDocument(); - }); - - test("handles failed delete when image is linked", async () => { - jest.spyOn(ApiService, "deleteImage").mockResolvedValue(false); - - await act(async () => { - render( - - ); - }); - - await waitFor(() => expect(ApiService.getImages).toHaveBeenCalled()); - - fireEvent.click(screen.getByTestId("delete-button-1")); - - await waitFor(() => expect(ApiService.deleteImage).toHaveBeenCalledWith("1")); - - expect(screen.getByText("Confirmer la suppression")).toBeInTheDocument(); - }); }); \ No newline at end of file diff --git a/client/src/components/ImageGallery/ImageGallery.tsx b/client/src/components/ImageGallery/ImageGallery.tsx index dfb63d8..de907d8 100644 --- a/client/src/components/ImageGallery/ImageGallery.tsx +++ b/client/src/components/ImageGallery/ImageGallery.tsx @@ -45,7 +45,7 @@ const ImageGallery: React.FC = ({ handleCopy }) => { const fetchImages = async () => { setLoading(true); - const data = await ApiService.getImages(imgPage, imgLimit); + const data = await ApiService.getUserImages(imgPage, imgLimit); setImages(data.images); setTotalImg(data.total); setLoading(false); @@ -156,7 +156,8 @@ const ImageGallery: React.FC = ({ handleCopy }) => { e.stopPropagation(); handleCopyFunction(obj.id); }} - color="primary" > + color="primary" + data-testid={`gallery-tab-copy-${obj.id}`} > @@ -167,7 +168,8 @@ const ImageGallery: React.FC = ({ handleCopy }) => { setImageToDelete(obj); setOpenDeleteDialog(true); }} - color="error" > + color="error" + data-testid={`gallery-tab-delete-${obj.id}`} >