diff --git a/client/package-lock.json b/client/package-lock.json index ac8831e..c5ecb22 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -29,6 +29,7 @@ "nanoid": "^5.1.5", "qrcode.react": "^4.2.0", "react": "^18.3.1", + "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.3.1", "react-modal": "^3.16.3", "react-router-dom": "^6.26.2", @@ -50,6 +51,7 @@ "@types/jest": "^29.5.13", "@types/node": "^22.14.0", "@types/react": "^18.2.15", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.2.7", "@types/react-latex": "^2.0.3", "@typescript-eslint/eslint-plugin": "^8.29.1", @@ -4512,6 +4514,15 @@ "@types/unist": "*" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.6.tgz", + "integrity": "sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -4651,6 +4662,15 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.8", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz", + "integrity": "sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.3.5", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", @@ -4671,6 +4691,17 @@ "@types/react": "*" } }, + "node_modules/@types/react-redux": { + "version": "7.1.34", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", + "integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.12", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", @@ -5918,6 +5949,14 @@ "node": ">= 8" } }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, "node_modules/css.escape": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", @@ -9871,6 +9910,11 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -11119,6 +11163,11 @@ } ] }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -11131,6 +11180,25 @@ "node": ">=0.10.0" } }, + "node_modules/react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==", + "deprecated": "react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672", + "dependencies": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.5 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", @@ -11172,6 +11240,35 @@ "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18 || ^19" } }, + "node_modules/react-redux": { + "version": "7.2.9", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", + "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "node_modules/react-router": { "version": "6.30.0", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", @@ -11247,6 +11344,14 @@ "node": ">=8" } }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -12778,6 +12883,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/use-memo-one": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/uuid": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", diff --git a/client/package.json b/client/package.json index aa4eecf..1ab46ea 100644 --- a/client/package.json +++ b/client/package.json @@ -33,6 +33,7 @@ "nanoid": "^5.1.5", "qrcode.react": "^4.2.0", "react": "^18.3.1", + "react-beautiful-dnd": "^13.1.1", "react-dom": "^18.3.1", "react-modal": "^3.16.3", "react-router-dom": "^6.26.2", @@ -54,6 +55,7 @@ "@types/jest": "^29.5.13", "@types/node": "^22.14.0", "@types/react": "^18.2.15", + "@types/react-beautiful-dnd": "^13.1.8", "@types/react-dom": "^18.2.7", "@types/react-latex": "^2.0.3", "@typescript-eslint/eslint-plugin": "^8.29.1", diff --git a/client/src/__tests__/components/Editor/Editor.test.tsx b/client/src/__tests__/components/Editor/Editor.test.tsx index 7548aac..fd0e642 100644 --- a/client/src/__tests__/components/Editor/Editor.test.tsx +++ b/client/src/__tests__/components/Editor/Editor.test.tsx @@ -1,80 +1,78 @@ -// Editor.test.tsx import React from 'react'; -import { render, fireEvent, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import '@testing-library/jest-dom'; -import Editor from 'src/components/Editor/Editor'; +import Editor from '../../../components/Editor/newEditor'; describe('Editor Component', () => { - const mockOnEditorChange = jest.fn(); + const mockOnValuesChange = jest.fn(); + const mockOnFocusQuestion = jest.fn(); const sampleProps = { label: 'Sample Label', - initialValue: 'Sample Initial Value', - onEditorChange: mockOnEditorChange + values: ['Question 1', 'Question 2'], + onValuesChange: mockOnValuesChange, + onFocusQuestion: mockOnFocusQuestion, }; beforeEach(() => { + mockOnValuesChange.mockClear(); + mockOnFocusQuestion.mockClear(); + }); + + test('renders the label correctly', () => { render(); + const label = screen.getByText('Sample Label'); + expect(label).toBeInTheDocument(); }); - it('renders correctly with initial value', () => { - const editorTextarea = screen.getByRole('textbox') as HTMLTextAreaElement; - expect(editorTextarea).toBeInTheDocument(); - expect(editorTextarea.value).toBe('Sample Initial Value'); + test('renders the correct number of questions', () => { + render(); + const questions = screen.getAllByRole('textbox'); + expect(questions.length).toBe(2); }); - it('calls onEditorChange callback when editor value changes', () => { - const editorTextarea = screen.getByRole('textbox') as HTMLTextAreaElement; - fireEvent.change(editorTextarea, { target: { value: 'Updated Value' } }); - expect(mockOnEditorChange).toHaveBeenCalledWith('Updated Value'); + test('calls onValuesChange with updated values when a question is changed', () => { + render(); + const questionInput = screen.getAllByRole('textbox')[0]; + fireEvent.change(questionInput, { target: { value: 'Updated Question 1' } }); + expect(mockOnValuesChange).toHaveBeenCalledWith(['Updated Question 1', 'Question 2']); }); - it('updates editor value when initialValue prop changes', () => { - const updatedProps = { - label: 'Updated Label', - initialValue: 'Updated Initial Value', - onEditorChange: mockOnEditorChange + test('calls onValuesChange with updated values when an empty question is deleted', () => { + const sampleProps = { + label: 'Test Editor', + values: [''], + onValuesChange: mockOnValuesChange, }; - - render(); - - const editorTextareas = screen.getAllByRole('textbox') as HTMLTextAreaElement[]; - const editorTextarea = editorTextareas[1]; - - expect(editorTextarea.value).toBe('Updated Initial Value'); + render(); + const deleteButton = screen.getAllByLabelText('delete')[0]; + fireEvent.click(deleteButton); + expect(mockOnValuesChange).toHaveBeenCalledWith([]); }); - test('should call change text with the correct value on textarea change', () => { - const updatedProps = { - label: 'Updated Label', - initialValue: 'Updated Initial Value', - onEditorChange: mockOnEditorChange - }; - - render(); - - const editorTextareas = screen.getAllByRole('textbox') as HTMLTextAreaElement[]; - const editorTextarea = editorTextareas[1]; - fireEvent.change(editorTextarea, { target: { value: 'New value' } }); - - expect(editorTextarea.value).toBe('New value'); + test('renders delete buttons for each question', () => { + render(); + const deleteButtons = screen.getAllByLabelText('delete'); + expect(deleteButtons.length).toBe(2); }); - test('should call onEditorChange with an empty string if textarea value is falsy', () => { - const updatedProps = { - label: 'Updated Label', - initialValue: 'Updated Initial Value', - onEditorChange: mockOnEditorChange - }; - - render(); - - const editorTextareas = screen.getAllByRole('textbox') as HTMLTextAreaElement[]; - const editorTextarea = editorTextareas[1]; - fireEvent.change(editorTextarea, { target: { value: '' } }); - - expect(editorTextarea.value).toBe(''); + test('calls onFocusQuestion with correct index when focus button is clicked', () => { + render(); + const focusButton = screen.getAllByLabelText('focus question')[1]; + fireEvent.click(focusButton); + expect(mockOnFocusQuestion).toHaveBeenCalledWith(1); }); + test('renders focus buttons for each question', () => { + render(); + const focusButtons = screen.getAllByLabelText('focus question'); + expect(focusButtons.length).toBe(2); + }); -}); + test('does not throw error when onFocusQuestion is not provided', () => { + const { onFocusQuestion, ...propsWithoutFocus } = sampleProps; + render(); + const focusButton = screen.getAllByLabelText('focus question')[0]; + expect(() => fireEvent.click(focusButton)).not.toThrow(); + }); +}); \ No newline at end of file diff --git a/client/src/__tests__/components/ReturnButton/ReturnButton.test.tsx b/client/src/__tests__/components/ReturnButton/ReturnButton.test.tsx index 8f8ef99..0766908 100644 --- a/client/src/__tests__/components/ReturnButton/ReturnButton.test.tsx +++ b/client/src/__tests__/components/ReturnButton/ReturnButton.test.tsx @@ -1,46 +1,148 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; -import ReturnButton from 'src/components/ReturnButton/ReturnButton'; import { useNavigate } from 'react-router-dom'; +import ReturnButton from '../../../components/ReturnButton/ReturnButton.tsx'; // Adjust path as needed +import ApiService from '../../../services/ApiService'; +// Mock react-router-dom's useNavigate jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - useNavigate: jest.fn() + useNavigate: jest.fn(), })); -describe('ReturnButton', () => { - test('navigates back when askConfirm is false', () => { - const navigateMock = jest.fn(); - (useNavigate as jest.Mock).mockReturnValue(navigateMock); - render(); - fireEvent.click(screen.getByText('Retour')); - expect(navigateMock).toHaveBeenCalledWith(-1); +// Mock ApiService +jest.mock('../../../services/ApiService', () => ({ + createQuiz: jest.fn(), + updateQuiz: jest.fn(), +})); + +describe('ReturnButton Component', () => { + const mockNavigate = jest.fn(); + const mockOnReturn = jest.fn(); + + beforeEach(() => { + // Reset mocks before each test + jest.clearAllMocks(); + (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + (ApiService.createQuiz as jest.Mock).mockResolvedValue({}); + (ApiService.updateQuiz as jest.Mock).mockResolvedValue({}); }); - test('shows confirmation modal when askConfirm is true', () => { - render(); - fireEvent.click(screen.getByText('Retour')); - const confirmButton = screen.getByTestId('modal-confirm-button'); - expect(confirmButton).toBeInTheDocument(); + test('renders the button with "Retour" text when not saving', () => { + render(); + expect(screen.getByText('Retour')).toBeInTheDocument(); }); - /*test('navigates back after confirming in the modal', () => { - const navigateMock = jest.fn(); - (useNavigate as jest.Mock).mockReturnValue(navigateMock); - render(); + test('renders "Enregistrement..." text when saving', async () => { + render(); fireEvent.click(screen.getByText('Retour')); - const confirmButton = screen.getByTestId('modal-confirm-button'); - fireEvent.click(confirmButton); - expect(navigateMock).toHaveBeenCalledWith(-1); - });*/ + expect(screen.getByText('Enregistrement...')).toBeInTheDocument(); + await waitFor(() => expect(screen.queryByText('Enregistrement...')).not.toBeInTheDocument()); + }); - test('cancels navigation when canceling in the modal', () => { - const navigateMock = jest.fn(); - (useNavigate as jest.Mock).mockReturnValue(navigateMock); - render(); + test('navigates to /teacher/dashboard by default when clicked', async () => { + render(); fireEvent.click(screen.getByText('Retour')); - fireEvent.click(screen.getByText('Annuler')); - expect(navigateMock).not.toHaveBeenCalled(); + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/teacher/dashboard')); + }); + + test('calls onReturn prop instead of navigating when provided', async () => { + render(); + fireEvent.click(screen.getByText('Retour')); + await waitFor(() => { + expect(mockOnReturn).toHaveBeenCalled(); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + test('disables button while saving', async () => { + render(); + const button = screen.getByText('Retour'); + fireEvent.click(button); + expect(button).toBeDisabled(); + await waitFor(() => expect(button).not.toBeDisabled()); + }); + + test('calls ApiService.createQuiz for new quiz with valid data', async () => { + const props = { + quizTitle: 'New Quiz', + quizContent: ['Q1', 'Q2'], + quizFolder: 'folder1', + isNewQuiz: true, + }; + render(); + fireEvent.click(screen.getByText('Retour')); + await waitFor(() => { + expect(ApiService.createQuiz).toHaveBeenCalledWith('New Quiz', ['Q1', 'Q2'], 'folder1'); + expect(ApiService.updateQuiz).not.toHaveBeenCalled(); + }); + }); + + test('calls ApiService.updateQuiz for existing quiz with valid data', async () => { + const props = { + quizId: 'quiz123', + quizTitle: 'Updated Quiz', + quizContent: ['Q1', 'Q2'], + isNewQuiz: false, + }; + render(); + fireEvent.click(screen.getByText('Retour')); + await waitFor(() => { + expect(ApiService.updateQuiz).toHaveBeenCalledWith('quiz123', 'Updated Quiz', ['Q1', 'Q2']); + expect(ApiService.createQuiz).not.toHaveBeenCalled(); + }); + }); + + test('does not call ApiService if quizTitle is missing for new quiz', async () => { + render(); + fireEvent.click(screen.getByText('Retour')); + await waitFor(() => { + expect(ApiService.createQuiz).not.toHaveBeenCalled(); + expect(ApiService.updateQuiz).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/teacher/dashboard'); + }); + }); + + test('does not call ApiService if quizId and quizTitle are missing for update', async () => { + render(); + fireEvent.click(screen.getByText('Retour')); + await waitFor(() => { + expect(ApiService.createQuiz).not.toHaveBeenCalled(); + expect(ApiService.updateQuiz).not.toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/teacher/dashboard'); + }); + }); + + test('navigates even if ApiService.createQuiz fails', async () => { + (ApiService.createQuiz as jest.Mock).mockRejectedValue(new Error('Save failed')); + const props = { + quizTitle: 'New Quiz', + quizContent: ['Q1'], + quizFolder: 'folder1', + isNewQuiz: true, + }; + render(); + fireEvent.click(screen.getByText('Retour')); + await waitFor(() => { + expect(ApiService.createQuiz).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/teacher/dashboard'); + }); + }); + + test('navigates even if ApiService.updateQuiz fails', async () => { + (ApiService.updateQuiz as jest.Mock).mockRejectedValue(new Error('Update failed')); + const props = { + quizId: 'quiz123', + quizTitle: 'Updated Quiz', + quizContent: ['Q1'], + isNewQuiz: false, + }; + render(); + fireEvent.click(screen.getByText('Retour')); + await waitFor(() => { + expect(ApiService.updateQuiz).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith('/teacher/dashboard'); + }); }); }); diff --git a/client/src/__tests__/pages/Teacher/EditorQuiz/EditorQuiz.test.tsx b/client/src/__tests__/pages/Teacher/EditorQuiz/EditorQuiz.test.tsx index bf9125e..36e6243 100644 --- a/client/src/__tests__/pages/Teacher/EditorQuiz/EditorQuiz.test.tsx +++ b/client/src/__tests__/pages/Teacher/EditorQuiz/EditorQuiz.test.tsx @@ -1,62 +1,55 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { MemoryRouter } from 'react-router-dom'; import QuizForm from '../../../../pages/Teacher/EditorQuiz/EditorQuiz'; -import { waitFor } from '@testing-library/react'; +// Mock localStorage with proper TypeScript types const localStorageMock = (() => { let store: Record = {}; return { - getItem: (key: string) => store[key] || null, - setItem: (key: string, value: string) => (store[key] = value.toString()), - clear: () => (store = {}), + getItem: (key: string): string | null => store[key] || null, + setItem: (key: string, value: string): void => { + store[key] = value.toString(); + }, + clear: (): void => { + store = {}; + }, }; })(); Object.defineProperty(window, 'localStorage', { value: localStorageMock }); +// Mock react-router-dom +const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), - useNavigate: jest.fn(), + useNavigate: () => mockNavigate, + useParams: () => ({ id: 'new' }), // Simulate the "new" route +})); + +// Mock ApiService +jest.mock('../../../../services/ApiService', () => ({ + getUserFolders: jest.fn(() => Promise.resolve([])), // Mock empty folder list + getQuiz: jest.fn(), + createQuiz: jest.fn(), + updateQuiz: jest.fn(), + uploadImage: jest.fn(), })); describe('QuizForm Component', () => { - test('renders QuizForm with default state for a new quiz', () => { + test('renders QuizForm with default state for a new quiz', async () => { render( - + ); - expect(screen.queryByText('Éditeur de quiz')).toBeInTheDocument(); - // expect(screen.queryByText('Éditeur')).toBeInTheDocument(); - expect(screen.queryByText('Prévisualisation')).toBeInTheDocument(); - }); - - test.skip('renders QuizForm for a new quiz', async () => { - const { container } = render( - - - - ); - - expect(screen.getByText(/Éditeur de quiz/i)).toBeInTheDocument(); - - // find the 'editor' text area - const editorTextArea = container.querySelector('textarea.editor'); - fireEvent.change(editorTextArea!, { target: { value: 'Sample question?' } }); - + // Wait for the component to render the title await waitFor(() => { - const sampleQuestionElements = screen.queryAllByText(/Sample question\?/i); - expect(sampleQuestionElements.length).toBeGreaterThan(0); + expect(screen.getByText('Éditeur de Quiz')).toBeInTheDocument(); }); - const saveButton = screen.getByText(/Enregistrer/i); - fireEvent.click(saveButton); - - await waitFor(() => { - expect(screen.getByText(/Sauvegarder le questionnaire/i)).toBeInTheDocument(); - }); + // Check for other expected elements + expect(screen.getByText('Prévisualisation')).toBeInTheDocument(); }); - -}); +}); \ No newline at end of file diff --git a/client/src/components/Editor/Editor.tsx b/client/src/components/Editor/Editor.tsx index 540cb4a..04ff8f8 100644 --- a/client/src/components/Editor/Editor.tsx +++ b/client/src/components/Editor/Editor.tsx @@ -1,21 +1,18 @@ -// Editor.tsx -import React, { useState, useRef } from 'react'; +import React, { useRef } from 'react'; import './editor.css'; import { TextareaAutosize } from '@mui/material'; interface EditorProps { label: string; - initialValue: string; + values: string[]; onEditorChange: (value: string) => void; } -const Editor: React.FC = ({ initialValue, onEditorChange, label }) => { - const [value, setValue] = useState(initialValue); +const Editor: React.FC = ({ label, values, onEditorChange }) => { const editorRef = useRef(null); function handleEditorChange(event: React.ChangeEvent) { const text = event.target.value; - setValue(text); onEditorChange(text || ''); } @@ -26,7 +23,7 @@ const Editor: React.FC = ({ initialValue, onEditorChange, label }) id="editor-textarea" ref={editorRef} onChange={handleEditorChange} - value={value} + value={values} className="editor" minRows={5} /> @@ -34,4 +31,4 @@ const Editor: React.FC = ({ initialValue, onEditorChange, label }) ); }; -export default Editor; +export default Editor; \ No newline at end of file diff --git a/client/src/components/Editor/editor.css b/client/src/components/Editor/editor.css index d47c8c8..64e7c69 100644 --- a/client/src/components/Editor/editor.css +++ b/client/src/components/Editor/editor.css @@ -6,4 +6,4 @@ padding-top: 10px; font-size: medium; resize: none; -} +} \ No newline at end of file diff --git a/client/src/components/Editor/newEditor.css b/client/src/components/Editor/newEditor.css new file mode 100644 index 0000000..d47c8c8 --- /dev/null +++ b/client/src/components/Editor/newEditor.css @@ -0,0 +1,9 @@ +.editor { + width: 100%; + height: 50vh; + background-color: #f8f9ff; + padding-left: 10px; + padding-top: 10px; + font-size: medium; + resize: none; +} diff --git a/client/src/components/Editor/newEditor.tsx b/client/src/components/Editor/newEditor.tsx new file mode 100644 index 0000000..1ee403b --- /dev/null +++ b/client/src/components/Editor/newEditor.tsx @@ -0,0 +1,203 @@ +import React, { useState } from 'react'; +import { TextField, Typography, IconButton, Box, Collapse, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; +import ExpandLessIcon from '@mui/icons-material/ExpandLess'; +import { DragDropContext, Droppable, Draggable } from 'react-beautiful-dnd'; + +interface EditorProps { + label: string; + values: string[]; + onValuesChange: (values: string[]) => void; + onFocusQuestion?: (index: number) => void; +} + +const Editor: React.FC = ({ label, values, onValuesChange, onFocusQuestion }) => { + const [collapsed, setCollapsed] = useState(Array(values.length).fill(false)); + const [dialogOpen, setDialogOpen] = useState(false); + const [deleteIndex, setDeleteIndex] = useState(null); + + const handleChange = (index: number) => (event: React.ChangeEvent) => { + const newValues = [...values]; + newValues[index] = event.target.value; + onValuesChange(newValues); + }; + + const handleDeleteQuestion = (index: number) => () => { + if (values[index].trim() === '') { + const newValues = values.filter((_, i) => i !== index); + onValuesChange(newValues); + setCollapsed((prev) => prev.filter((_, i) => i !== index)); + } else { + setDeleteIndex(index); + setDialogOpen(true); + } + }; + + const handleConfirmDelete = () => { + if (deleteIndex !== null) { + const newValues = values.filter((_, i) => i !== deleteIndex); + onValuesChange(newValues); + setCollapsed((prev) => prev.filter((_, i) => i !== deleteIndex)); + } + setDialogOpen(false); + setDeleteIndex(null); + }; + + const handleCancelDelete = () => { + setDialogOpen(false); + setDeleteIndex(null); + }; + + const handleFocusQuestion = (index: number) => () => { + if (onFocusQuestion) { + onFocusQuestion(index); + } + }; + + const handleToggleCollapse = (index: number) => () => { + setCollapsed((prev) => { + const newCollapsed = [...prev]; + newCollapsed[index] = !newCollapsed[index]; + return newCollapsed; + }); + }; + + const onDragEnd = (result: any) => { + if (!result.destination) return; + + const newValues = [...values]; + const [reorderedItem] = newValues.splice(result.source.index, 1); + newValues.splice(result.destination.index, 0, reorderedItem); + onValuesChange(newValues); + + const newCollapsed = [...collapsed]; + const [reorderedCollapsed] = newCollapsed.splice(result.source.index, 1); + newCollapsed.splice(result.destination.index, 0, reorderedCollapsed); + setCollapsed(newCollapsed); + }; + + if (collapsed.length !== values.length) { + setCollapsed((prev) => { + const newCollapsed = [...prev]; + while (newCollapsed.length < values.length) newCollapsed.push(false); + while (newCollapsed.length > values.length) newCollapsed.pop(); + return newCollapsed; + }); + } + + return ( +
+ + {label} + + + + + {(provided) => ( +
+ {values.map((value, index) => ( + + {(provided) => ( + + + + Question {index + 1} + + + + {collapsed[index] ? : } + + + + + + + + + + + + + + )} + + ))} + {provided.placeholder} +
+ )} +
+
+ + {/* Confirmation Dialog */} + + Suppression + + + Confirmez vous la suppression de Question {deleteIndex !== null ? deleteIndex + 1 : ''} ? + + + + + + + +
+ ); +}; + +export default Editor; \ No newline at end of file diff --git a/client/src/components/GiftTemplate/GIFTTemplatePreview.tsx b/client/src/components/GiftTemplate/GIFTTemplatePreview.tsx index 9da21c8..6e223c6 100644 --- a/client/src/components/GiftTemplate/GIFTTemplatePreview.tsx +++ b/client/src/components/GiftTemplate/GIFTTemplatePreview.tsx @@ -12,43 +12,47 @@ interface GIFTTemplatePreviewProps { const GIFTTemplatePreview: React.FC = ({ questions, - hideAnswers = false + hideAnswers = false, }) => { const [error, setError] = useState(''); const [isPreviewReady, setIsPreviewReady] = useState(false); - const [items, setItems] = useState(''); + const [questionItems, setQuestionItems] = useState([]); // Array of HTML strings for each question useEffect(() => { try { - let previewHTML = ''; + const previewItems: string[] = []; questions.forEach((giftQuestion) => { try { const question = parse(giftQuestion); - previewHTML += Template(question[0], { + const html = Template(question[0], { preview: true, - theme: 'light' + theme: 'light', }); + previewItems.push(html); } catch (error) { let errorMsg: string; if (error instanceof UnsupportedQuestionTypeError) { errorMsg = ErrorTemplate(giftQuestion, `Erreur: ${error.message}`); } else if (error instanceof Error) { - errorMsg = ErrorTemplate(giftQuestion, `Erreur GIFT: ${error.message}`); + errorMsg = ErrorTemplate(giftQuestion, `Erreur GIFT: ${error.message}`); } else { - errorMsg = ErrorTemplate(giftQuestion, 'Erreur inconnue'); + errorMsg = ErrorTemplate(giftQuestion, 'Erreur inconnue'); } - previewHTML += `
${errorMsg}
`; + previewItems.push(`
${errorMsg}
`); } }); if (hideAnswers) { - const svgRegex = /]*>([\s\S]*?)<\/svg>/gi; - previewHTML = previewHTML.replace(svgRegex, ''); - const placeholderRegex = /(placeholder=")[^"]*(")/gi; - previewHTML = previewHTML.replace(placeholderRegex, '$1$2'); + previewItems.forEach((item, index) => { + const svgRegex = /]*>([\s\S]*?)<\/svg>/gi; + const placeholderRegex = /(placeholder=")[^"]*(")/gi; + previewItems[index] = item + .replace(svgRegex, '') + .replace(placeholderRegex, '$1$2'); + }); } - setItems(previewHTML); + setQuestionItems(previewItems); setIsPreviewReady(true); } catch (error: unknown) { if (error instanceof Error) { @@ -57,7 +61,7 @@ const GIFTTemplatePreview: React.FC = ({ setError('Une erreur est survenue durant le chargement de la prévisualisation.'); } } - }, [questions]); + }, [questions, hideAnswers]); const PreviewComponent = () => ( @@ -65,8 +69,13 @@ const GIFTTemplatePreview: React.FC = ({
{error}
) : isPreviewReady ? (
- -
+ {questionItems.map((item, index) => ( +
+ ))}
) : (
Chargement de la prévisualisation...
@@ -77,4 +86,4 @@ const GIFTTemplatePreview: React.FC = ({ return ; }; -export default GIFTTemplatePreview; +export default GIFTTemplatePreview; \ No newline at end of file diff --git a/client/src/components/ReturnButton/ReturnButton.tsx b/client/src/components/ReturnButton/ReturnButton.tsx index a184356..8ec0bb1 100644 --- a/client/src/components/ReturnButton/ReturnButton.tsx +++ b/client/src/components/ReturnButton/ReturnButton.tsx @@ -1,65 +1,69 @@ -// GoBackButton.tsx -import React from 'react'; -import { useState } from 'react'; +import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import ConfirmDialog from '../ConfirmDialog/ConfirmDialog'; import { Button } from '@mui/material'; import { ChevronLeft } from '@mui/icons-material'; +import ApiService from '../../services/ApiService'; // Assuming this is where save logic lives interface Props { onReturn?: () => void; - askConfirm?: boolean; - message?: string; + quizTitle?: string; // Quiz title to save + quizContent?: string[]; // Quiz content to save + quizFolder?: string; // Folder ID to save + quizId?: string; // Quiz ID for updates (optional) + isNewQuiz?: boolean; // Flag to determine create or update } const ReturnButton: React.FC = ({ - askConfirm = false, - message = 'Êtes-vous sûr de vouloir quitter la page ?', - onReturn + onReturn, + quizTitle = '', + quizContent = [], + quizFolder = '', + quizId, + isNewQuiz = false, }) => { const navigate = useNavigate(); - const [showDialog, setShowDialog] = useState(false); + const [isSaving, setIsSaving] = useState(false); // Optional: to show saving state - const handleOnReturnButtonClick = () => { - if (askConfirm) { - setShowDialog(true); - } else { + const handleOnReturnButtonClick = async () => { + setIsSaving(true); + try { + // Automatically save the quiz + if (isNewQuiz && quizTitle && quizFolder) { + await ApiService.createQuiz(quizTitle, quizContent, quizFolder); + } else if (quizId && quizTitle) { + await ApiService.updateQuiz(quizId, quizTitle, quizContent); + } + // If no title/folder, proceed without saving (optional behavior) handleOnReturn(); + } catch (error) { + console.error('Error saving quiz on return:', error); + // Still navigate even if save fails, to avoid trapping the user + handleOnReturn(); + } finally { + setIsSaving(false); } }; - const handleConfirm = () => { - setShowDialog(false); - handleOnReturn(); - }; - const handleOnReturn = () => { if (onReturn) { onReturn(); } else { - navigate(-1); + navigate('/teacher/dashboard'); // Navigate to dashboard instead of -1 } }; return ( -
+
- setShowDialog(false)} - buttonOrderType="warning" - />
); }; diff --git a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx index 2b220da..12c0f65 100644 --- a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx +++ b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx @@ -1,22 +1,27 @@ -// EditorQuiz.tsx -import React, { useState, useEffect, CSSProperties } from 'react'; +import React, { useState, useEffect, useRef, CSSProperties } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { FolderType } from '../../../Types/FolderType'; +import NewEditor from 'src/components/Editor/newEditor'; import Editor from 'src/components/Editor/Editor'; + import GiftCheatSheet from 'src/components/GIFTCheatSheet/GiftCheatSheet'; import GIFTTemplatePreview from 'src/components/GiftTemplate/GIFTTemplatePreview'; import { QuizType } from '../../../Types/QuizType'; + import SaveIcon from '@mui/icons-material/Save'; -import './editorQuiz.css'; -import { Button, TextField, NativeSelect, Divider } from '@mui/material'; +import './EditorQuiz.css'; +import { Button, TextField, NativeSelect, Divider, Dialog, DialogTitle, DialogActions, DialogContent, MenuItem, Select, Snackbar } from '@mui/material'; import ReturnButton from 'src/components/ReturnButton/ReturnButton'; import ImageGalleryModal from 'src/components/ImageGallery/ImageGalleryModal/ImageGalleryModal'; import ApiService from '../../../services/ApiService'; import { escapeForGIFT } from '../../../utils/giftUtils'; + +import { Upload } from '@mui/icons-material'; +import SaveIcon from '@mui/icons-material/Save'; import { ENV_VARIABLES } from 'src/constants'; interface EditQuizParams { @@ -30,7 +35,7 @@ const QuizForm: React.FC = () => { const [filteredValue, setFilteredValue] = useState([]); const { id } = useParams(); - const [value, setValue] = useState(''); + const [values, setValues] = useState([]); const [isNewQuiz, setNewQuiz] = useState(false); const [quiz, setQuiz] = useState(null); const navigate = useNavigate(); @@ -41,6 +46,31 @@ const QuizForm: React.FC = () => { }; const [showScrollButton, setShowScrollButton] = useState(false); + const [isImagesCollapsed, setIsImagesCollapsed] = useState(true); + const [isCheatSheetCollapsed, setIsCheatSheetCollapsed] = useState(true); + const [isUploadCollapsed, setIsUploadCollapsed] = useState(true); + const [snackbarOpen, setSnackbarOpen] = useState(false); + const [snackbarMessage, setSnackbarMessage] = useState(''); + const [copySuccess, setCopySuccess] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const fileInputRef = useRef(null); + + const [useNewEditor, setUserNewEditor] = useState(false); + + const QuestionVraiFaux = "::Exemple de question vrai/faux:: \n 2+2 \\= 4 ? {T} //Utilisez les valeurs {T}, {F}, {TRUE} et {FALSE}."; + const QuestionChoixMul = "::Ville capitale du Canada:: \nQuelle ville est la capitale du Canada? {\n~ Toronto\n~ Montréal\n= Ottawa #Rétroaction spécifique.\n} // Commentaire non visible (au besoin)"; + const QuestionChoixMulMany = "::Villes canadiennes:: \n Quelles villes trouve-t-on au Canada? { \n~ %33.3% Montréal \n ~ %33.3% Ottawa \n ~ %33.3% Vancouver \n ~ %-100% New York \n ~ %-100% Paris \n#### Rétroaction globale de la question. \n} // Utilisez tilde (signe de vague) pour toutes les réponses. // On doit indiquer le pourcentage de chaque réponse."; + const QuestionCourte = "::Clé et porte:: \n Avec quoi ouvre-t-on une porte? { \n= clé \n= clef \n} // Permet de fournir plusieurs bonnes réponses. // Note: La casse n'est pas prise en compte."; + const QuestionNum = "::Question numérique avec marge:: \nQuel est un nombre de 1 à 5 ? {\n#3:2\n}\n \n// Plage mathématique spécifiée avec des points de fin d'intervalle. \n ::Question numérique avec plage:: \n Quel est un nombre de 1 à 5 ? {\n#1..5\n} \n\n// Réponses numériques multiples avec crédit partiel et commentaires.\n::Question numérique avec plusieurs réponses::\nQuand est né Ulysses S. Grant ? {\n# =1822:0 # Correct ! Crédit complet. \n=%50%1822:2 # Il est né en 1822. Demi-crédit pour être proche.\n}"; + + const templates = [ + { label: 'Vrai/Faux', value: QuestionVraiFaux }, + { label: 'Choix multiples R1', value: QuestionChoixMul }, + { label: 'Choix multiples R2+', value: QuestionChoixMulMany }, + { label: 'Réponse courte', value: QuestionCourte }, + { label: 'Numérique', value: QuestionNum }, + ]; + const scrollToTop = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }; @@ -100,7 +130,7 @@ const QuizForm: React.FC = () => { setQuizTitle(title); setSelectedFolder(folderId); setFilteredValue(content); - setValue(quiz.content.join('\n\n')); + setValues(content); } catch (error) { window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`) @@ -112,13 +142,25 @@ const QuizForm: React.FC = () => { fetchData(); }, [id]); - function handleUpdatePreview(value: string) { + const handleAddQuestion = () => { + console.log("Adding question"); + console.log("Current values:", values); // Log current state + setValues([...values, '']); + console.log("Updated values:", [...values, '']); // Log new state + }; + + const newHandleUpdatePreview = (newValues: string[]) => { + setValues(newValues); + setFilteredValue(newValues.filter(value => value.trim() !== '')); + }; + + const handleUpdatePreview = (value: string) => { if (value !== '') { - setValue(value); + setValues([value]); } // split value when there is at least one blank line - const linesArray = value.split(/\n{2,}/); + const linesArray = value.split(/\n{2,}/); // if the first item in linesArray is blank, remove it if (linesArray[0] === '') linesArray.shift(); @@ -134,7 +176,6 @@ const QuizForm: React.FC = () => { const handleQuizSave = async () => { try { - // check if everything is there if (quizTitle == '') { alert("Veuillez choisir un titre"); return; @@ -153,7 +194,8 @@ const QuizForm: React.FC = () => { } } - navigate('/teacher/dashboard'); + setSnackbarMessage('Quiz enregistré avec succès!'); + setSnackbarOpen(true); } catch (error) { window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`) console.log(error) @@ -165,124 +207,212 @@ const QuizForm: React.FC = () => { return
Chargement...
; } + const handleSnackbarClose = () => { + setSnackbarOpen(false); + }; + + 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 handleFocusQuestion = (index: number) => { + const previewElement = document.querySelector('.preview-column'); + if (previewElement) { + const questionElements = previewElement.querySelectorAll('.question-item'); + if (questionElements[index]) { + questionElements[index].scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } + }; + + const copyToClipboard = (text: string, label: string) => { + navigator.clipboard.writeText(text) + .then(() => { + setCopySuccess(`Copié dans le presse-papier: ${label}`); + }) + .catch((error) => console.error('Clipboard error:', error)); + }; + + const handleSelectChange = (value: string, label: string) => { + copyToClipboard(value, label); + }; + + const toggleEditor = () => { + setUserNewEditor(!useNewEditor); + } const handleCopyImage = (id: string) => { const escLink = `${ENV_VARIABLES.BACKEND_URL}/api/image/get/${id}`; setImageLinks(prevLinks => [...prevLinks, escLink]); } return ( -
-
- - - + +
+ + + + +
+ + +
-
-
Éditeur de quiz
-
- - {/*

Éditeur

*/} - -
- - - - {folders.map((folder: FolderType) => ( - - ))} - -
- + setCopySuccess(null)} message={copySuccess} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} key={copySuccess ? 'open' : 'close'} />
-
- - -
-
-

Mes images :

- + {useNewEditor ? ( + + ) : ( + + )} + {useNewEditor && ( + + )} +
+
+ + {!isUploadCollapsed && ( +
+ + setDialogOpen(false)}> + Erreur + Veuillez d'abord choisir une image à téléverser. + + + + +
+ )}
- -
+ +
+ + {!isImagesCollapsed && (
-
(Voir section
- -

9. Images

-
-
ci-dessous
-
)
-
- - Cliquez sur un lien pour le copier -
-
    - {imageLinks.map((link, index) => { - const imgTag = `[markdown]![alt_text](${escapeForGIFT(link)} "texte de l'infobulle") {T}`; - return ( -
  • - handleCopyToClipboard(imgTag)}> - {imgTag} - -
  • - ); - })} -
+

Mes images :

+
+
+
(Voir section
+ +

9. Images

+
+
ci-dessous
+
)
+
+ - Cliquez sur un lien pour le copier +
+
    + {imageLinks.map((link, index) => { + const imgTag = `![alt_text](${escapeForGIFT(link)} "texte de l'infobulle")`; + return ( +
  • + handleCopyToClipboard(imgTag)}>{imgTag} +
  • + ); + })} +
+
+
+ )} +
+ +
+ + {!isCheatSheetCollapsed && }
- - -
-
+

Prévisualisation

@@ -290,20 +420,15 @@ const QuizForm: React.FC = () => {
-
{showScrollButton && ( - )} + +
); }; diff --git a/client/src/pages/Teacher/EditorQuiz/editorQuiz.css b/client/src/pages/Teacher/EditorQuiz/editorQuiz.css index 79157ff..299e4ac 100644 --- a/client/src/pages/Teacher/EditorQuiz/editorQuiz.css +++ b/client/src/pages/Teacher/EditorQuiz/editorQuiz.css @@ -80,6 +80,24 @@ input[type="file"] { .quizEditor .editSection .preview { flex: 50%; padding: 5px; - overflow: auto; + height: 100%; + position: relative; } + +.quizEditor { + margin: 0 -2rem; /* Counteract the padding */ + width: calc(100% + 4rem); /* Expand to fill padded area */ +} + +.sticky-buttons { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + position: sticky; + top: 0; + background: #fff; + z-index: 1000; + padding: 8px 0; +} \ No newline at end of file