diff --git a/client/package-lock.json b/client/package-lock.json index e2c6890..a6590ca 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -28,6 +28,7 @@ "marked": "^14.1.2", "nanoid": "^5.1.2", "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", @@ -48,6 +49,7 @@ "@types/jest": "^29.5.13", "@types/node": "^22.13.5", "@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.25.0", @@ -4555,6 +4557,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", @@ -4695,6 +4706,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", @@ -4715,6 +4735,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", @@ -5978,6 +6009,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", @@ -9945,6 +9984,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", @@ -11188,6 +11232,11 @@ ], "license": "MIT" }, + "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", @@ -11200,6 +11249,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", @@ -11241,6 +11309,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", @@ -11316,6 +11413,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", @@ -12841,6 +12946,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": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", diff --git a/client/package.json b/client/package.json index b6d62e4..b06d5de 100644 --- a/client/package.json +++ b/client/package.json @@ -32,6 +32,7 @@ "marked": "^14.1.2", "nanoid": "^5.1.2", "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", @@ -52,6 +53,7 @@ "@types/jest": "^29.5.13", "@types/node": "^22.13.5", "@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.25.0", diff --git a/client/src/__tests__/components/Editor/Editor.test.tsx b/client/src/__tests__/components/Editor/Editor.test.tsx index 7548aac..d09d5a0 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/Editor'; 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..1ee403b 100644 --- a/client/src/components/Editor/Editor.tsx +++ b/client/src/components/Editor/Editor.tsx @@ -1,37 +1,203 @@ -// Editor.tsx -import React, { useState, useRef } from 'react'; -import './editor.css'; -import { TextareaAutosize } from '@mui/material'; +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; - initialValue: string; - onEditorChange: (value: string) => void; + values: string[]; + onValuesChange: (values: string[]) => void; + onFocusQuestion?: (index: number) => void; } -const Editor: React.FC = ({ initialValue, onEditorChange, label }) => { - const [value, setValue] = useState(initialValue); - const editorRef = useRef(null); +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); - function handleEditorChange(event: React.ChangeEvent) { - const text = event.target.value; - setValue(text); - onEditorChange(text || ''); + 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; +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 89f822a..3a10f03 100644 --- a/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx +++ b/client/src/pages/Teacher/EditorQuiz/EditorQuiz.tsx @@ -11,7 +11,7 @@ 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, Dialog, DialogTitle, DialogActions, DialogContent, Snackbar } from '@mui/material'; import ReturnButton from 'src/components/ReturnButton/ReturnButton'; import ApiService from '../../../services/ApiService'; @@ -29,7 +29,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(); @@ -42,6 +42,12 @@ const QuizForm: React.FC = () => { const [dialogOpen, setDialogOpen] = useState(false); 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 scrollToTop = () => { window.scrollTo({ top: 0, behavior: 'smooth' }); }; @@ -101,7 +107,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`) @@ -113,21 +119,17 @@ const QuizForm: React.FC = () => { fetchData(); }, [id]); - function handleUpdatePreview(value: string) { - if (value !== '') { - setValue(value); - } + const handleAddQuestion = () => { + console.log("Adding question"); + console.log("Current values:", values); // Log current state + setValues([...values, '']); + console.log("Updated values:", [...values, '']); // Log new state + }; - // split value when there is at least one blank line - const linesArray = value.split(/\n{2,}/); - - // if the first item in linesArray is blank, remove it - if (linesArray[0] === '') linesArray.shift(); - - if (linesArray[linesArray.length - 1] === '') linesArray.pop(); - - setFilteredValue(linesArray); - } + const handleUpdatePreview = (newValues: string[]) => { + setValues(newValues); + setFilteredValue(newValues.filter(value => value.trim() !== '')); + }; const handleQuizTitleChange = (event: React.ChangeEvent) => { setQuizTitle(event.target.value); @@ -135,7 +137,6 @@ const QuizForm: React.FC = () => { const handleQuizSave = async () => { try { - // check if everything is there if (quizTitle == '') { alert("Veuillez choisir un titre"); return; @@ -154,7 +155,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) @@ -166,6 +168,10 @@ const QuizForm: React.FC = () => { return
Chargement...
; } + const handleSnackbarClose = () => { + setSnackbarOpen(false); + }; + const handleSaveImage = async () => { try { const inputElement = document.getElementById('file-input') as HTMLInputElement; @@ -204,16 +210,29 @@ const QuizForm: React.FC = () => { 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' }); + } + } + }; + return (
-
Éditeur de quiz
+
Éditeur de Quiz
@@ -253,74 +272,121 @@ const QuizForm: React.FC = () => {
- -
-
-
-
+

Prévisualisation

@@ -328,7 +394,6 @@ const QuizForm: React.FC = () => {
-
{showScrollButton && ( @@ -342,9 +407,17 @@ const QuizForm: React.FC = () => { ↑ )} + +
); -}; +}; const scrollToTopButtonStyle: CSSProperties = { position: 'fixed', diff --git a/client/src/pages/Teacher/EditorQuiz/editorQuiz.css b/client/src/pages/Teacher/EditorQuiz/editorQuiz.css index 79157ff..a797443 100644 --- a/client/src/pages/Teacher/EditorQuiz/editorQuiz.css +++ b/client/src/pages/Teacher/EditorQuiz/editorQuiz.css @@ -80,6 +80,12 @@ 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 */ +} \ No newline at end of file