This commit is contained in:
Philippe Côté 2025-04-05 15:51:21 -04:00 committed by GitHub
commit 14785d20d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 743 additions and 277 deletions

113
client/package-lock.json generated
View file

@ -28,6 +28,7 @@
"marked": "^14.1.2", "marked": "^14.1.2",
"nanoid": "^5.1.2", "nanoid": "^5.1.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
@ -48,6 +49,7 @@
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^18.2.15", "@types/react": "^18.2.15",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/react-latex": "^2.0.3", "@types/react-latex": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^8.25.0", "@typescript-eslint/eslint-plugin": "^8.25.0",
@ -4555,6 +4557,15 @@
"@types/unist": "*" "@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": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@ -4695,6 +4706,15 @@
"csstype": "^3.0.2" "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": { "node_modules/@types/react-dom": {
"version": "18.3.5", "version": "18.3.5",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.5.tgz",
@ -4715,6 +4735,17 @@
"@types/react": "*" "@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": { "node_modules/@types/react-transition-group": {
"version": "4.4.12", "version": "4.4.12",
"resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz",
@ -5978,6 +6009,14 @@
"node": ">= 8" "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": { "node_modules/css.escape": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
@ -9945,6 +9984,11 @@
"url": "https://opencollective.com/unified" "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": { "node_modules/merge-stream": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -11188,6 +11232,11 @@
], ],
"license": "MIT" "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": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@ -11200,6 +11249,25 @@
"node": ">=0.10.0" "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": { "node_modules/react-dom": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "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" "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": { "node_modules/react-router": {
"version": "6.30.0", "version": "6.30.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.0.tgz",
@ -11316,6 +11413,14 @@
"node": ">=8" "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": { "node_modules/reflect.getprototypeof": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@ -12841,6 +12946,14 @@
"requires-port": "^1.0.0" "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": { "node_modules/uuid": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",

View file

@ -32,6 +32,7 @@
"marked": "^14.1.2", "marked": "^14.1.2",
"nanoid": "^5.1.2", "nanoid": "^5.1.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-modal": "^3.16.3", "react-modal": "^3.16.3",
"react-router-dom": "^6.26.2", "react-router-dom": "^6.26.2",
@ -52,6 +53,7 @@
"@types/jest": "^29.5.13", "@types/jest": "^29.5.13",
"@types/node": "^22.13.5", "@types/node": "^22.13.5",
"@types/react": "^18.2.15", "@types/react": "^18.2.15",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^18.2.7", "@types/react-dom": "^18.2.7",
"@types/react-latex": "^2.0.3", "@types/react-latex": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^8.25.0", "@typescript-eslint/eslint-plugin": "^8.25.0",

View file

@ -1,80 +1,78 @@
// Editor.test.tsx
import React from 'react'; 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 '@testing-library/jest-dom';
import Editor from 'src/components/Editor/Editor'; import Editor from '../../../components/Editor/Editor';
describe('Editor Component', () => { describe('Editor Component', () => {
const mockOnEditorChange = jest.fn(); const mockOnValuesChange = jest.fn();
const mockOnFocusQuestion = jest.fn();
const sampleProps = { const sampleProps = {
label: 'Sample Label', label: 'Sample Label',
initialValue: 'Sample Initial Value', values: ['Question 1', 'Question 2'],
onEditorChange: mockOnEditorChange onValuesChange: mockOnValuesChange,
onFocusQuestion: mockOnFocusQuestion,
}; };
beforeEach(() => { beforeEach(() => {
mockOnValuesChange.mockClear();
mockOnFocusQuestion.mockClear();
});
test('renders the label correctly', () => {
render(<Editor {...sampleProps} />); render(<Editor {...sampleProps} />);
const label = screen.getByText('Sample Label');
expect(label).toBeInTheDocument();
}); });
it('renders correctly with initial value', () => { test('renders the correct number of questions', () => {
const editorTextarea = screen.getByRole('textbox') as HTMLTextAreaElement; render(<Editor {...sampleProps} />);
expect(editorTextarea).toBeInTheDocument(); const questions = screen.getAllByRole('textbox');
expect(editorTextarea.value).toBe('Sample Initial Value'); expect(questions.length).toBe(2);
}); });
it('calls onEditorChange callback when editor value changes', () => { test('calls onValuesChange with updated values when a question is changed', () => {
const editorTextarea = screen.getByRole('textbox') as HTMLTextAreaElement; render(<Editor {...sampleProps} />);
fireEvent.change(editorTextarea, { target: { value: 'Updated Value' } }); const questionInput = screen.getAllByRole('textbox')[0];
expect(mockOnEditorChange).toHaveBeenCalledWith('Updated Value'); fireEvent.change(questionInput, { target: { value: 'Updated Question 1' } });
expect(mockOnValuesChange).toHaveBeenCalledWith(['Updated Question 1', 'Question 2']);
}); });
it('updates editor value when initialValue prop changes', () => { test('calls onValuesChange with updated values when an empty question is deleted', () => {
const updatedProps = { const sampleProps = {
label: 'Updated Label', label: 'Test Editor',
initialValue: 'Updated Initial Value', values: [''],
onEditorChange: mockOnEditorChange onValuesChange: mockOnValuesChange,
}; };
render(<Editor {...sampleProps} />);
render(<Editor {...updatedProps} />); const deleteButton = screen.getAllByLabelText('delete')[0];
fireEvent.click(deleteButton);
const editorTextareas = screen.getAllByRole('textbox') as HTMLTextAreaElement[]; expect(mockOnValuesChange).toHaveBeenCalledWith([]);
const editorTextarea = editorTextareas[1];
expect(editorTextarea.value).toBe('Updated Initial Value');
}); });
test('should call change text with the correct value on textarea change', () => { test('renders delete buttons for each question', () => {
const updatedProps = { render(<Editor {...sampleProps} />);
label: 'Updated Label', const deleteButtons = screen.getAllByLabelText('delete');
initialValue: 'Updated Initial Value', expect(deleteButtons.length).toBe(2);
onEditorChange: mockOnEditorChange
};
render(<Editor {...updatedProps} />);
const editorTextareas = screen.getAllByRole('textbox') as HTMLTextAreaElement[];
const editorTextarea = editorTextareas[1];
fireEvent.change(editorTextarea, { target: { value: 'New value' } });
expect(editorTextarea.value).toBe('New value');
}); });
test('should call onEditorChange with an empty string if textarea value is falsy', () => { test('calls onFocusQuestion with correct index when focus button is clicked', () => {
const updatedProps = { render(<Editor {...sampleProps} />);
label: 'Updated Label', const focusButton = screen.getAllByLabelText('focus question')[1];
initialValue: 'Updated Initial Value', fireEvent.click(focusButton);
onEditorChange: mockOnEditorChange expect(mockOnFocusQuestion).toHaveBeenCalledWith(1);
};
render(<Editor {...updatedProps} />);
const editorTextareas = screen.getAllByRole('textbox') as HTMLTextAreaElement[];
const editorTextarea = editorTextareas[1];
fireEvent.change(editorTextarea, { target: { value: '' } });
expect(editorTextarea.value).toBe('');
}); });
test('renders focus buttons for each question', () => {
render(<Editor {...sampleProps} />);
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(<Editor {...propsWithoutFocus} />);
const focusButton = screen.getAllByLabelText('focus question')[0];
expect(() => fireEvent.click(focusButton)).not.toThrow();
});
});

View file

@ -1,46 +1,148 @@
import React from 'react'; 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 '@testing-library/jest-dom';
import ReturnButton from 'src/components/ReturnButton/ReturnButton';
import { useNavigate } from 'react-router-dom'; 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.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useNavigate: jest.fn() useNavigate: jest.fn(),
})); }));
describe('ReturnButton', () => { // Mock ApiService
test('navigates back when askConfirm is false', () => { jest.mock('../../../services/ApiService', () => ({
const navigateMock = jest.fn(); createQuiz: jest.fn(),
(useNavigate as jest.Mock).mockReturnValue(navigateMock); updateQuiz: jest.fn(),
render(<ReturnButton askConfirm={false} />); }));
fireEvent.click(screen.getByText('Retour'));
expect(navigateMock).toHaveBeenCalledWith(-1); 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', () => { test('renders the button with "Retour" text when not saving', () => {
render(<ReturnButton askConfirm={true} />); render(<ReturnButton />);
fireEvent.click(screen.getByText('Retour')); expect(screen.getByText('Retour')).toBeInTheDocument();
const confirmButton = screen.getByTestId('modal-confirm-button');
expect(confirmButton).toBeInTheDocument();
}); });
/*test('navigates back after confirming in the modal', () => { test('renders "Enregistrement..." text when saving', async () => {
const navigateMock = jest.fn(); render(<ReturnButton quizTitle="Test Quiz" quizFolder="folder1" isNewQuiz />);
(useNavigate as jest.Mock).mockReturnValue(navigateMock);
render(<ReturnButton askConfirm={true} />);
fireEvent.click(screen.getByText('Retour')); fireEvent.click(screen.getByText('Retour'));
const confirmButton = screen.getByTestId('modal-confirm-button'); expect(screen.getByText('Enregistrement...')).toBeInTheDocument();
fireEvent.click(confirmButton); await waitFor(() => expect(screen.queryByText('Enregistrement...')).not.toBeInTheDocument());
expect(navigateMock).toHaveBeenCalledWith(-1); });
});*/
test('cancels navigation when canceling in the modal', () => { test('navigates to /teacher/dashboard by default when clicked', async () => {
const navigateMock = jest.fn(); render(<ReturnButton />);
(useNavigate as jest.Mock).mockReturnValue(navigateMock);
render(<ReturnButton askConfirm={true} />);
fireEvent.click(screen.getByText('Retour')); fireEvent.click(screen.getByText('Retour'));
fireEvent.click(screen.getByText('Annuler')); await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith('/teacher/dashboard'));
expect(navigateMock).not.toHaveBeenCalled(); });
test('calls onReturn prop instead of navigating when provided', async () => {
render(<ReturnButton onReturn={mockOnReturn} />);
fireEvent.click(screen.getByText('Retour'));
await waitFor(() => {
expect(mockOnReturn).toHaveBeenCalled();
expect(mockNavigate).not.toHaveBeenCalled();
});
});
test('disables button while saving', async () => {
render(<ReturnButton quizTitle="Test Quiz" quizFolder="folder1" isNewQuiz />);
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(<ReturnButton {...props} />);
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(<ReturnButton {...props} />);
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(<ReturnButton quizFolder="folder1" isNewQuiz />);
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(<ReturnButton />);
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(<ReturnButton {...props} />);
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(<ReturnButton {...props} />);
fireEvent.click(screen.getByText('Retour'));
await waitFor(() => {
expect(ApiService.updateQuiz).toHaveBeenCalled();
expect(mockNavigate).toHaveBeenCalledWith('/teacher/dashboard');
});
}); });
}); });

View file

@ -1,62 +1,55 @@
import React from 'react'; 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 '@testing-library/jest-dom';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import QuizForm from '../../../../pages/Teacher/EditorQuiz/EditorQuiz'; import QuizForm from '../../../../pages/Teacher/EditorQuiz/EditorQuiz';
import { waitFor } from '@testing-library/react';
// Mock localStorage with proper TypeScript types
const localStorageMock = (() => { const localStorageMock = (() => {
let store: Record<string, string> = {}; let store: Record<string, string> = {};
return { return {
getItem: (key: string) => store[key] || null, getItem: (key: string): string | null => store[key] || null,
setItem: (key: string, value: string) => (store[key] = value.toString()), setItem: (key: string, value: string): void => {
clear: () => (store = {}), store[key] = value.toString();
},
clear: (): void => {
store = {};
},
}; };
})(); })();
Object.defineProperty(window, 'localStorage', { value: localStorageMock }); Object.defineProperty(window, 'localStorage', { value: localStorageMock });
// Mock react-router-dom
const mockNavigate = jest.fn();
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('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', () => { 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( render(
<MemoryRouter initialEntries={['/teacher/editor-quiz/new']}> <MemoryRouter initialEntries={['/teacher/editor-quiz/new']}>
<QuizForm /> <QuizForm />
</MemoryRouter> </MemoryRouter>
); );
expect(screen.queryByText('Éditeur de quiz')).toBeInTheDocument(); // Wait for the component to render the title
// expect(screen.queryByText('Éditeur')).toBeInTheDocument();
expect(screen.queryByText('Prévisualisation')).toBeInTheDocument();
});
test.skip('renders QuizForm for a new quiz', async () => {
const { container } = render(
<MemoryRouter initialEntries={['/teacher/editor-quiz']}>
<QuizForm />
</MemoryRouter>
);
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?' } });
await waitFor(() => { await waitFor(() => {
const sampleQuestionElements = screen.queryAllByText(/Sample question\?/i); expect(screen.getByText('Éditeur de Quiz')).toBeInTheDocument();
expect(sampleQuestionElements.length).toBeGreaterThan(0);
}); });
const saveButton = screen.getByText(/Enregistrer/i); // Check for other expected elements
fireEvent.click(saveButton); expect(screen.getByText('Prévisualisation')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/Sauvegarder le questionnaire/i)).toBeInTheDocument();
});
}); });
});
});

View file

@ -1,37 +1,203 @@
// Editor.tsx import React, { useState } from 'react';
import React, { useState, useRef } from 'react'; import { TextField, Typography, IconButton, Box, Collapse, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button } from '@mui/material';
import './editor.css'; import DeleteIcon from '@mui/icons-material/Delete';
import { TextareaAutosize } from '@mui/material'; 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 { interface EditorProps {
label: string; label: string;
initialValue: string; values: string[];
onEditorChange: (value: string) => void; onValuesChange: (values: string[]) => void;
onFocusQuestion?: (index: number) => void;
} }
const Editor: React.FC<EditorProps> = ({ initialValue, onEditorChange, label }) => { const Editor: React.FC<EditorProps> = ({ label, values, onValuesChange, onFocusQuestion }) => {
const [value, setValue] = useState(initialValue); const [collapsed, setCollapsed] = useState<boolean[]>(Array(values.length).fill(false));
const editorRef = useRef<HTMLTextAreaElement | null>(null); const [dialogOpen, setDialogOpen] = useState(false);
const [deleteIndex, setDeleteIndex] = useState<number | null>(null);
function handleEditorChange(event: React.ChangeEvent<HTMLTextAreaElement>) { const handleChange = (index: number) => (event: React.ChangeEvent<HTMLInputElement>) => {
const text = event.target.value; const newValues = [...values];
setValue(text); newValues[index] = event.target.value;
onEditorChange(text || ''); 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 ( return (
<label> <div>
<h4>{label}</h4> <Typography variant="h6" fontWeight="bold" style={{ marginBottom: '24px' }}>
<TextareaAutosize {label}
id="editor-textarea" </Typography>
ref={editorRef}
onChange={handleEditorChange} <DragDropContext onDragEnd={onDragEnd}>
value={value} <Droppable droppableId="questions">
className="editor" {(provided) => (
minRows={5} <div {...provided.droppableProps} ref={provided.innerRef}>
/> {values.map((value, index) => (
</label> <Draggable key={index} draggableId={`question-${index}`} index={index}>
{(provided) => (
<Box
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
sx={{
marginBottom: '8px',
boxShadow: '0px 2px 4px rgba(0, 0, 0, 0.1)',
border: '1px solid rgba(0, 0, 0, 0.1)',
padding: '16px',
borderRadius: '4px',
...provided.draggableProps.style,
}}
>
<Box display="flex" alignItems="center" justifyContent="space-between">
<Typography variant="subtitle1" fontWeight="bold" style={{ marginBottom: '8px' }}>
Question {index + 1}
</Typography>
<Box>
<IconButton
onClick={handleToggleCollapse(index)}
aria-label="toggle collapse"
sx={{
color: 'gray',
'&:hover': { color: 'blue' },
mr: 1,
}}
>
{collapsed[index] ? <ExpandMoreIcon /> : <ExpandLessIcon />}
</IconButton>
<IconButton
onClick={handleFocusQuestion(index)}
aria-label="focus question"
sx={{
color: 'gray',
'&:hover': { color: 'blue' },
mr: 1,
}}
>
<VisibilityIcon />
</IconButton>
<IconButton
onClick={handleDeleteQuestion(index)}
aria-label="delete"
sx={{
color: 'light-gray',
'&:hover': { color: 'red' },
}}
>
<DeleteIcon />
</IconButton>
</Box>
</Box>
<Collapse in={!collapsed[index]}>
<TextField
value={value}
onChange={handleChange(index)}
fullWidth
multiline
minRows={4}
maxRows={Infinity}
variant="outlined"
style={{ overflow: 'auto' }}
/>
</Collapse>
</Box>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
{/* Confirmation Dialog */}
<Dialog
open={dialogOpen}
onClose={handleCancelDelete}
aria-labelledby="delete-confirmation-title"
aria-describedby="delete-confirmation-description"
>
<DialogTitle id="delete-confirmation-title" sx={{ textAlign: 'center'}}>Suppression</DialogTitle>
<DialogContent>
<DialogContentText id="delete-confirmation-description">
Confirmez vous la suppression de Question {deleteIndex !== null ? deleteIndex + 1 : ''} ?
</DialogContentText>
</DialogContent>
<DialogActions sx={{ justifyContent: 'center', pb: 2 }}>
<Button onClick={handleCancelDelete} color="primary" sx={{ mx: 1 }}>
Annuler
</Button>
<Button onClick={handleConfirmDelete} color="error" sx={{ mx: 1 }} autoFocus>
Supprimer
</Button>
</DialogActions>
</Dialog>
</div>
); );
}; };
export default Editor; export default Editor;

View file

@ -12,43 +12,47 @@ interface GIFTTemplatePreviewProps {
const GIFTTemplatePreview: React.FC<GIFTTemplatePreviewProps> = ({ const GIFTTemplatePreview: React.FC<GIFTTemplatePreviewProps> = ({
questions, questions,
hideAnswers = false hideAnswers = false,
}) => { }) => {
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isPreviewReady, setIsPreviewReady] = useState(false); const [isPreviewReady, setIsPreviewReady] = useState(false);
const [items, setItems] = useState(''); const [questionItems, setQuestionItems] = useState<string[]>([]); // Array of HTML strings for each question
useEffect(() => { useEffect(() => {
try { try {
let previewHTML = ''; const previewItems: string[] = [];
questions.forEach((giftQuestion) => { questions.forEach((giftQuestion) => {
try { try {
const question = parse(giftQuestion); const question = parse(giftQuestion);
previewHTML += Template(question[0], { const html = Template(question[0], {
preview: true, preview: true,
theme: 'light' theme: 'light',
}); });
previewItems.push(html);
} catch (error) { } catch (error) {
let errorMsg: string; let errorMsg: string;
if (error instanceof UnsupportedQuestionTypeError) { if (error instanceof UnsupportedQuestionTypeError) {
errorMsg = ErrorTemplate(giftQuestion, `Erreur: ${error.message}`); errorMsg = ErrorTemplate(giftQuestion, `Erreur: ${error.message}`);
} else if (error instanceof Error) { } else if (error instanceof Error) {
errorMsg = ErrorTemplate(giftQuestion, `Erreur GIFT: ${error.message}`); errorMsg = ErrorTemplate(giftQuestion, `Erreur GIFT: ${error.message}`);
} else { } else {
errorMsg = ErrorTemplate(giftQuestion, 'Erreur inconnue'); errorMsg = ErrorTemplate(giftQuestion, 'Erreur inconnue');
} }
previewHTML += `<div label="error-message">${errorMsg}</div>`; previewItems.push(`<div label="error-message">${errorMsg}</div>`);
} }
}); });
if (hideAnswers) { if (hideAnswers) {
const svgRegex = /<svg[^>]*>([\s\S]*?)<\/svg>/gi; previewItems.forEach((item, index) => {
previewHTML = previewHTML.replace(svgRegex, ''); const svgRegex = /<svg[^>]*>([\s\S]*?)<\/svg>/gi;
const placeholderRegex = /(placeholder=")[^"]*(")/gi; const placeholderRegex = /(placeholder=")[^"]*(")/gi;
previewHTML = previewHTML.replace(placeholderRegex, '$1$2'); previewItems[index] = item
.replace(svgRegex, '')
.replace(placeholderRegex, '$1$2');
});
} }
setItems(previewHTML); setQuestionItems(previewItems);
setIsPreviewReady(true); setIsPreviewReady(true);
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof Error) { if (error instanceof Error) {
@ -57,7 +61,7 @@ const GIFTTemplatePreview: React.FC<GIFTTemplatePreviewProps> = ({
setError('Une erreur est survenue durant le chargement de la prévisualisation.'); setError('Une erreur est survenue durant le chargement de la prévisualisation.');
} }
} }
}, [questions]); }, [questions, hideAnswers]);
const PreviewComponent = () => ( const PreviewComponent = () => (
<React.Fragment> <React.Fragment>
@ -65,8 +69,13 @@ const GIFTTemplatePreview: React.FC<GIFTTemplatePreviewProps> = ({
<div className="error">{error}</div> <div className="error">{error}</div>
) : isPreviewReady ? ( ) : isPreviewReady ? (
<div data-testid="preview-container"> <div data-testid="preview-container">
{questionItems.map((item, index) => (
<div dangerouslySetInnerHTML={{ __html: FormattedTextTemplate({ format: 'html', text: items }) }}></div> <div
key={index}
className="question-item"
dangerouslySetInnerHTML={{ __html: FormattedTextTemplate({ format: 'html', text: item }) }}
/>
))}
</div> </div>
) : ( ) : (
<div className="loading">Chargement de la prévisualisation...</div> <div className="loading">Chargement de la prévisualisation...</div>
@ -77,4 +86,4 @@ const GIFTTemplatePreview: React.FC<GIFTTemplatePreviewProps> = ({
return <PreviewComponent />; return <PreviewComponent />;
}; };
export default GIFTTemplatePreview; export default GIFTTemplatePreview;

View file

@ -1,65 +1,69 @@
// GoBackButton.tsx import React, { useState } from 'react';
import React from 'react';
import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ConfirmDialog from '../ConfirmDialog/ConfirmDialog';
import { Button } from '@mui/material'; import { Button } from '@mui/material';
import { ChevronLeft } from '@mui/icons-material'; import { ChevronLeft } from '@mui/icons-material';
import ApiService from '../../services/ApiService'; // Assuming this is where save logic lives
interface Props { interface Props {
onReturn?: () => void; onReturn?: () => void;
askConfirm?: boolean; quizTitle?: string; // Quiz title to save
message?: string; 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<Props> = ({ const ReturnButton: React.FC<Props> = ({
askConfirm = false, onReturn,
message = 'Êtes-vous sûr de vouloir quitter la page ?', quizTitle = '',
onReturn quizContent = [],
quizFolder = '',
quizId,
isNewQuiz = false,
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const [showDialog, setShowDialog] = useState(false); const [isSaving, setIsSaving] = useState(false); // Optional: to show saving state
const handleOnReturnButtonClick = () => { const handleOnReturnButtonClick = async () => {
if (askConfirm) { setIsSaving(true);
setShowDialog(true); try {
} else { // 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(); 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 = () => { const handleOnReturn = () => {
if (onReturn) { if (onReturn) {
onReturn(); onReturn();
} else { } else {
navigate(-1); navigate('/teacher/dashboard'); // Navigate to dashboard instead of -1
} }
}; };
return ( return (
<div className='returnButton'> <div className="returnButton">
<Button <Button
variant="text" variant="text"
startIcon={<ChevronLeft />} startIcon={<ChevronLeft />}
onClick={handleOnReturnButtonClick} onClick={handleOnReturnButtonClick}
color="primary" color="primary"
sx={{ marginLeft: '-0.5rem', fontSize: 16 }} sx={{ marginLeft: '-0.5rem', fontSize: 16 }}
disabled={isSaving} // Disable button while saving
> >
Retour {isSaving ? 'Enregistrement...' : 'Retour'}
</Button> </Button>
<ConfirmDialog
open={showDialog}
title="Confirmer"
message={message}
onConfirm={handleConfirm}
onCancel={() => setShowDialog(false)}
buttonOrderType="warning"
/>
</div> </div>
); );
}; };

View file

@ -11,7 +11,7 @@ import GIFTTemplatePreview from 'src/components/GiftTemplate/GIFTTemplatePreview
import { QuizType } from '../../../Types/QuizType'; import { QuizType } from '../../../Types/QuizType';
import './editorQuiz.css'; 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 ReturnButton from 'src/components/ReturnButton/ReturnButton';
import ApiService from '../../../services/ApiService'; import ApiService from '../../../services/ApiService';
@ -29,7 +29,7 @@ const QuizForm: React.FC = () => {
const [filteredValue, setFilteredValue] = useState<string[]>([]); const [filteredValue, setFilteredValue] = useState<string[]>([]);
const { id } = useParams<EditQuizParams>(); const { id } = useParams<EditQuizParams>();
const [value, setValue] = useState(''); const [values, setValues] = useState<string[]>([]);
const [isNewQuiz, setNewQuiz] = useState(false); const [isNewQuiz, setNewQuiz] = useState(false);
const [quiz, setQuiz] = useState<QuizType | null>(null); const [quiz, setQuiz] = useState<QuizType | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
@ -42,6 +42,12 @@ const QuizForm: React.FC = () => {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [showScrollButton, setShowScrollButton] = 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 = () => { const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
@ -101,7 +107,7 @@ const QuizForm: React.FC = () => {
setQuizTitle(title); setQuizTitle(title);
setSelectedFolder(folderId); setSelectedFolder(folderId);
setFilteredValue(content); setFilteredValue(content);
setValue(quiz.content.join('\n\n')); setValues(content);
} catch (error) { } catch (error) {
window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`) window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`)
@ -113,21 +119,17 @@ const QuizForm: React.FC = () => {
fetchData(); fetchData();
}, [id]); }, [id]);
function handleUpdatePreview(value: string) { const handleAddQuestion = () => {
if (value !== '') { console.log("Adding question");
setValue(value); 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 handleUpdatePreview = (newValues: string[]) => {
const linesArray = value.split(/\n{2,}/); setValues(newValues);
setFilteredValue(newValues.filter(value => value.trim() !== ''));
// 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 handleQuizTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => { const handleQuizTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setQuizTitle(event.target.value); setQuizTitle(event.target.value);
@ -135,7 +137,6 @@ const QuizForm: React.FC = () => {
const handleQuizSave = async () => { const handleQuizSave = async () => {
try { try {
// check if everything is there
if (quizTitle == '') { if (quizTitle == '') {
alert("Veuillez choisir un titre"); alert("Veuillez choisir un titre");
return; return;
@ -154,7 +155,8 @@ const QuizForm: React.FC = () => {
} }
} }
navigate('/teacher/dashboard'); setSnackbarMessage('Quiz enregistré avec succès!');
setSnackbarOpen(true);
} catch (error) { } catch (error) {
window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`) window.alert(`Une erreur est survenue.\n Veuillez réessayer plus tard`)
console.log(error) console.log(error)
@ -166,6 +168,10 @@ const QuizForm: React.FC = () => {
return <div>Chargement...</div>; return <div>Chargement...</div>;
} }
const handleSnackbarClose = () => {
setSnackbarOpen(false);
};
const handleSaveImage = async () => { const handleSaveImage = async () => {
try { try {
const inputElement = document.getElementById('file-input') as HTMLInputElement; const inputElement = document.getElementById('file-input') as HTMLInputElement;
@ -204,16 +210,29 @@ const QuizForm: React.FC = () => {
navigator.clipboard.writeText(link); 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 ( return (
<div className='quizEditor'> <div className='quizEditor'>
<div className='editHeader'> <div className='editHeader'>
<ReturnButton <ReturnButton
askConfirm quizTitle={quizTitle}
message={`Êtes-vous sûr de vouloir quitter l'éditeur sans sauvegarder le questionnaire?`} quizContent={filteredValue}
quizFolder={selectedFolder}
quizId={quiz?._id}
isNewQuiz={isNewQuiz}
/> />
<div className='title'>Éditeur de quiz</div> <div className='title'>Éditeur de Quiz</div>
<div className='dumb'></div> <div className='dumb'></div>
</div> </div>
@ -253,74 +272,121 @@ const QuizForm: React.FC = () => {
<div className='edit'> <div className='edit'>
<Editor <Editor
label="Contenu GIFT du quiz:" label=""
initialValue={value} values={values}
onEditorChange={handleUpdatePreview} /> onValuesChange={handleUpdatePreview}
onFocusQuestion={handleFocusQuestion} />
<div className='images'> <Button variant="contained" onClick={handleAddQuestion}>
<div className='upload'> Ajouter une question
<label className="dropArea"> </Button>
<input type="file" id="file-input" className="file-input"
accept="image/jpeg, image/png" <div className="images">
multiple {/* Collapsible Upload Section */}
ref={fileInputRef} /> <div style={{ marginTop: '8px' }}>
<Button
<Button
variant="outlined" variant="outlined"
aria-label='Téléverser' onClick={() => setIsUploadCollapsed(!isUploadCollapsed)}
onClick={handleSaveImage}> style={{ padding: '4px 8px', fontSize: '12px', marginBottom: '4px', width: '40%' }}
Téléverser <Upload /> >
</Button> {isUploadCollapsed ? 'Afficher Téléverser image' : 'Masquer Téléverser image'}
</Button>
</label> {!isUploadCollapsed && (
<Dialog <div className="upload">
open={dialogOpen} <label className="dropArea">
onClose={() => setDialogOpen(false)} > <input
<DialogTitle>Erreur</DialogTitle> type="file"
<DialogContent> id="file-input"
Veuillez d&apos;abord choisir une image à téléverser. className="file-input"
</DialogContent> accept="image/jpeg, image/png"
<DialogActions> multiple
<Button onClick={() => setDialogOpen(false)} color="primary"> ref={fileInputRef}
OK />
</Button> <Button
</DialogActions> variant="outlined"
</Dialog> aria-label="Téléverser"
onClick={handleSaveImage}
>
Téléverser <Upload />
</Button>
</label>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
<DialogTitle>Erreur</DialogTitle>
<DialogContent>
Veuillez d'abord choisir une image à téléverser.
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)} color="primary">
OK
</Button>
</DialogActions>
</Dialog>
</div>
)}
</div> </div>
<h4>Mes images :</h4> {/* Collapsible Images Section */}
<div> <div style={{ marginTop: '2px' }}>
<Button
variant="outlined"
onClick={() => setIsImagesCollapsed(!isImagesCollapsed)}
style={{ padding: '4px 8px', fontSize: '12px', marginBottom: '4px', width: '40%' }}
>
{isImagesCollapsed ? 'Afficher Mes images' : 'Masquer Mes images'}
</Button>
{!isImagesCollapsed && (
<div> <div>
<div style={{ display: "inline" }}>(Voir section </div> <h4>Mes images :</h4>
<a href="#images-section"style={{ textDecoration: "none" }} onClick={scrollToImagesSection}> <div>
<u><em><h4 style={{ display: "inline" }}> 9. Images </h4></em></u> <div>
</a> <div style={{ display: 'inline' }}>(Voir section </div>
<div style={{ display: "inline" }}> ci-dessous</div> <a
<div style={{ display: "inline" }}>)</div> href="#images-section"
<br /> style={{ textDecoration: 'none' }}
<em> - Cliquez sur un lien pour le copier</em> onClick={scrollToImagesSection}
</div> >
<ul> <u>
{imageLinks.map((link, index) => { <em>
const imgTag = `![alt_text](${escapeForGIFT(link)} "texte de l'infobulle")`; <h4 style={{ display: 'inline' }}> 9. Images </h4>
return ( </em>
<li key={index}> </u>
<code </a>
onClick={() => handleCopyToClipboard(imgTag)}> <div style={{ display: 'inline' }}> ci-dessous</div>
{imgTag} <div style={{ display: 'inline' }}>)</div>
</code> <br />
</li> <em> - Cliquez sur un lien pour le copier</em>
); </div>
})} <ul>
</ul> {imageLinks.map((link, index) => {
const imgTag = `![alt_text](${escapeForGIFT(link)} "texte de l'infobulle")`;
return (
<li key={index}>
<code onClick={() => handleCopyToClipboard(imgTag)}>
{imgTag}
</code>
</li>
);
})}
</ul>
</div>
</div>
)}
</div>
{/* Collapsible CheatSheet Section */}
<div style={{ marginTop: '2px' }}>
<Button
variant="outlined"
onClick={() => setIsCheatSheetCollapsed(!isCheatSheetCollapsed)}
style={{ padding: '4px 8px', fontSize: '12px', marginBottom: '4px', width: '40%' }}
>
{isCheatSheetCollapsed ? 'Afficher CheatSheet' : 'Masquer CheatSheet'}
</Button>
{!isCheatSheetCollapsed && <GiftCheatSheet />}
</div> </div>
</div> </div>
<GiftCheatSheet />
</div> </div>
<div className='preview'> <div className="preview">
<div className="preview-column"> <div className="preview-column">
<h4>Prévisualisation</h4> <h4>Prévisualisation</h4>
<div> <div>
@ -328,7 +394,6 @@ const QuizForm: React.FC = () => {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{showScrollButton && ( {showScrollButton && (
@ -342,9 +407,17 @@ const QuizForm: React.FC = () => {
</Button> </Button>
)} )}
<Snackbar
open={snackbarOpen}
autoHideDuration={3000} // Hide after 3 seconds
onClose={handleSnackbarClose}
message={snackbarMessage}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }} // Lower-right corner
/>
</div> </div>
); );
}; };
const scrollToTopButtonStyle: CSSProperties = { const scrollToTopButtonStyle: CSSProperties = {
position: 'fixed', position: 'fixed',

View file

@ -80,6 +80,12 @@ input[type="file"] {
.quizEditor .editSection .preview { .quizEditor .editSection .preview {
flex: 50%; flex: 50%;
padding: 5px; padding: 5px;
overflow: auto; overflow: auto;
height: 100%;
position: relative;
} }
.quizEditor {
margin: 0 -2rem; /* Counteract the padding */
width: calc(100% + 4rem); /* Expand to fill padded area */
}