diff --git a/src/components/question-editor/QuestionEditor.constants.tsx b/src/components/question-editor/QuestionEditor.constants.tsx new file mode 100644 index 000000000..93e60b0a3 --- /dev/null +++ b/src/components/question-editor/QuestionEditor.constants.tsx @@ -0,0 +1,23 @@ +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' +import NotesIcon from '@mui/icons-material/Notes' +import RuleIcon from '@mui/icons-material/Rule' + +import { SizeEnum } from '~/types' + +export const sortQuestions = [ + { + icon: , + title: 'questionPage.questionType.multipleChoice', + value: 'multipleChoice' + }, + { + icon: , + title: 'questionPage.questionType.openAnswer', + value: 'openAnswer' + }, + { + icon: , + title: 'questionPage.questionType.oneAnswer', + value: 'oneAnswer' + } +] diff --git a/src/components/question-editor/QuestionEditor.styles.ts b/src/components/question-editor/QuestionEditor.styles.ts new file mode 100644 index 000000000..6f63edb92 --- /dev/null +++ b/src/components/question-editor/QuestionEditor.styles.ts @@ -0,0 +1,66 @@ +import { commonShadow } from '~/styles/app-theme/custom-shadows' + +export const styles = { + root: { + display: 'flex', + padding: '16px 24px', + flexDirection: 'column', + alignItems: 'flex-start', + borderRadius: '6px', + boxShadow: commonShadow + }, + questionHeader: { + width: '100%', + display: 'flex', + justifyContent: 'space-between' + }, + group: { + width: '100%' + }, + options: { + display: 'flex', + alignItems: 'center', + gap: '16px' + }, + divider: { + alignSelf: 'stretch', + mb: '24px' + }, + inputItem: { + color: 'basic.black', + width: '100%', + mb: '8px', + '.MuiFormControlLabel-label': { + width: '100%' + } + }, + answer: { + width: '100%', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center' + }, + iconWrapper: { + display: 'flex', + padding: '8px', + justifyContent: 'center', + alignItems: 'center', + borderRadius: '4px', + backgroundColor: 'basic.grey' + }, + selectContainer: { + '.MuiOutlinedInput-notchedOutline': { border: 0 } + }, + addRadio: (isEmptyAnswer: boolean) => ({ + display: 'flex', + alignItems: 'center', + color: 'primary.600', + cursor: isEmptyAnswer ? 'auto' : 'pointer', + '& label': { + mr: '8px' + } + }), + addIcon: (isEmptyAnswer: boolean) => ({ + color: isEmptyAnswer ? 'primary.300' : 'primary.700' + }) +} diff --git a/src/components/question-editor/QuestionEditor.tsx b/src/components/question-editor/QuestionEditor.tsx new file mode 100644 index 000000000..82b8be940 --- /dev/null +++ b/src/components/question-editor/QuestionEditor.tsx @@ -0,0 +1,194 @@ +import { ChangeEvent, MouseEvent, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Box from '@mui/material/Box' +import Divider from '@mui/material/Divider' +import IconButton from '@mui/material/IconButton' +import DeleteIcon from '@mui/icons-material/Delete' +import CloseIcon from '@mui/icons-material/Close' +import AddIcon from '@mui/icons-material/Add' +import Checkbox from '@mui/material/Checkbox' +import FormControlLabel from '@mui/material/FormControlLabel' +import FormGroup from '@mui/material/FormGroup' +import InputBase from '@mui/material/InputBase' +import Radio from '@mui/material/Radio' +import RadioGroup from '@mui/material/RadioGroup' + +import AppTextField from '~/components/app-text-field/AppTextField' +import AppSelect from '~/components/app-select/AppSelect' + +import { styles } from '~/components/question-editor/QuestionEditor.styles' +import { sortQuestions } from '~/components/question-editor/QuestionEditor.constants' +import { Answer, SizeEnum, TextFieldVariantEnum } from '~/types' + +const QuestionEditor = () => { + const [questionType, setQuestionType] = useState(sortQuestions[0]) + const [question, setQuestion] = useState('') + const [answers, setAnswers] = useState([]) + const [answer, setAnswer] = useState('') + + const { t } = useTranslation() + + const isMultipleChoice = questionType.value === sortQuestions[0].value + const isOpenAnswer = questionType.value === sortQuestions[1].value + const isSingleChoice = questionType.value === sortQuestions[2].value + const isEmptyAnswer = answers[answers.length - 1]?.text === '' + + const setTypeValue = (value: string) => { + const questionOption = sortQuestions.find((item) => item.value === value) + + setQuestionType(questionOption ?? sortQuestions[0]) + } + + const handleQuestion = (event: ChangeEvent) => { + setQuestion(event.target.value) + } + + const sortOptions = sortQuestions.map(({ icon, title, value }) => ({ + title: t(title), + value, + icon + })) + + const handleOptionChange = (index: number, checked: boolean) => { + const updatedAnswers = [...answers] + + if (isMultipleChoice) { + updatedAnswers[index].isCorrect = checked + } else if (isSingleChoice) { + updatedAnswers.forEach((answer, i) => { + answer.isCorrect = i === index + }) + } + + setAnswers(updatedAnswers) + } + + const onChangeInput = ( + event: ChangeEvent, + index: number + ) => { + const currentValue = event.target.value + setAnswers((prevAnswers) => { + const updatedAnswers = [...prevAnswers] + updatedAnswers[index] = { + ...updatedAnswers[index], + text: currentValue + } + return updatedAnswers + }) + } + + const addNewOneAnswer = (event: MouseEvent) => { + event.preventDefault() + if (!isEmptyAnswer) { + setAnswers((prev) => [ + ...prev, + { + id: answers.length, + text: '', + isCorrect: false + } + ]) + } + } + + const deleteRadioButton = (id: number) => { + const updatedAnswers = answers.filter((item) => item.id !== id) + setAnswers(updatedAnswers) + } + + const handleTypeChange = (value: string) => { + setAnswers((prevAnswers) => + prevAnswers.map((answer) => ({ ...answer, isCorrect: false })) + ) + setTypeValue(value) + } + + const handleAnswer = (event: ChangeEvent) => + setAnswer(event.target.value) + + const options = answers.map((item) => ( + + : } + label={ + onChangeInput(e, item.id)} + placeholder={t('questionPage.writeYourAnswer')} + value={item.text} + /> + } + onChange={(_, checked) => handleOptionChange(item.id, checked)} + sx={styles.inputItem} + value={item.id} + /> + deleteRadioButton(item.id)}> + + + + )) + + return ( + + + + {questionType.icon} + + + + + + + + + + + + + {isMultipleChoice && {options}} + + {isSingleChoice && {options}} + + {!isOpenAnswer && ( + + : } + disabled={isEmptyAnswer} + label={t('questionPage.addNewOne')} + value={0} + /> + + + )} + + {isOpenAnswer && ( + + )} + + ) +} + +export default QuestionEditor diff --git a/src/constants/translations/en/index.ts b/src/constants/translations/en/index.ts index dcb185a35..f11c209f8 100644 --- a/src/constants/translations/en/index.ts +++ b/src/constants/translations/en/index.ts @@ -34,6 +34,7 @@ import myOffersPage from './my-offers-page.json' import myResourcesPage from './my-resources-page.json' import chatPage from './chat.json' import lesson from './lesson.json' +import questionPage from './question-page.json' const en = { translations: { @@ -72,7 +73,8 @@ const en = { myOffersPage, myResourcesPage, chatPage, - lesson + lesson, + questionPage } } diff --git a/src/constants/translations/en/question-page.json b/src/constants/translations/en/question-page.json new file mode 100644 index 000000000..d6176a65b --- /dev/null +++ b/src/constants/translations/en/question-page.json @@ -0,0 +1,11 @@ +{ + "question": "Question", + "answer": "Answer", + "questionType": { + "multipleChoice": "Multiple Choice", + "openAnswer": "Open Answer", + "oneAnswer": "One Answer" + }, + "addNewOne": "Add new one", + "writeYourAnswer": "Write your answer" +} diff --git a/src/constants/translations/ua/index.ts b/src/constants/translations/ua/index.ts index d467cb985..3a99447c7 100644 --- a/src/constants/translations/ua/index.ts +++ b/src/constants/translations/ua/index.ts @@ -21,6 +21,7 @@ import myOffersPage from './my-offers-page.json' import myResourcesPage from './my-resources-page.json' import chatPage from './chat.json' import lesson from './lesson.json' +import questionPage from './question-page.json' const ua = { translations: { @@ -46,7 +47,8 @@ const ua = { myOffersPage, myResourcesPage, chatPage, - lesson + lesson, + questionPage } } diff --git a/src/constants/translations/ua/question-page.json b/src/constants/translations/ua/question-page.json new file mode 100644 index 000000000..83326ef92 --- /dev/null +++ b/src/constants/translations/ua/question-page.json @@ -0,0 +1,11 @@ +{ + "question": "Питання", + "answer": "Відповідь", + "questionType": { + "multipleChoice": "Множинний вибір", + "openAnswer": "Відкрита відповідь", + "oneAnswer": "Одна відповідь" + }, + "addNewOne": "Добавити ще одне питання", + "writeYourAnswer": "Напишіть свою відповідь" +} diff --git a/src/types/common/enums/common.enums.ts b/src/types/common/enums/common.enums.ts index bc91fe6b0..338c241d4 100644 --- a/src/types/common/enums/common.enums.ts +++ b/src/types/common/enums/common.enums.ts @@ -40,7 +40,8 @@ export enum TypographyVariantEnum { } export enum TextFieldVariantEnum { - Standard = 'standard' + Standard = 'standard', + Outlined = 'outlined' } export enum DrawerVariantEnum { diff --git a/src/types/index.ts b/src/types/index.ts index 2e6f8b21e..51fb6bd75 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -22,3 +22,4 @@ export * from '~/types/attachment/attachment.index' export * from '~/types/my-attachments/myAttachments.index' export * from '~/types/quizzes/quizzes.index' export * from '~/types/lesson/lesson.index' +export * from '~/types/questions/questions.index' diff --git a/src/types/questions/interfaces/questions.interface.ts b/src/types/questions/interfaces/questions.interface.ts new file mode 100644 index 000000000..88fd4dc06 --- /dev/null +++ b/src/types/questions/interfaces/questions.interface.ts @@ -0,0 +1,5 @@ +export interface Answer { + id: number + text: string + isCorrect: boolean +} diff --git a/src/types/questions/questions.index.ts b/src/types/questions/questions.index.ts new file mode 100644 index 000000000..83e2b538a --- /dev/null +++ b/src/types/questions/questions.index.ts @@ -0,0 +1 @@ +export * from '~/types/questions/interfaces/questions.interface' diff --git a/src/types/quizzes/interfaces/quizzes.interface.ts b/src/types/quizzes/interfaces/quizzes.interface.ts index fe2eea307..94d01d8fc 100644 --- a/src/types/quizzes/interfaces/quizzes.interface.ts +++ b/src/types/quizzes/interfaces/quizzes.interface.ts @@ -1,14 +1,14 @@ -import { RequestParams, CommonEntityFields, UserResponse } from '~/types' +import { + RequestParams, + CommonEntityFields, + UserResponse, + Answer +} from '~/types' export interface QuizzesParams extends RequestParams { title: string } -export interface Answer { - text: string - isCorrect: boolean -} - export interface QuestionWithAnswers { question: string answers: Answer[] diff --git a/tests/unit/components/question-editor/QuestionEditor.spec.jsx b/tests/unit/components/question-editor/QuestionEditor.spec.jsx new file mode 100644 index 000000000..9e5c0801a --- /dev/null +++ b/tests/unit/components/question-editor/QuestionEditor.spec.jsx @@ -0,0 +1,154 @@ +import QuestionEditor from '~/components/question-editor/QuestionEditor' +import { screen, fireEvent } from '@testing-library/react' +import { renderWithProviders } from '~tests/test-utils' +import { beforeEach, describe } from 'vitest' + +describe('QuestionEditor component', () => { + beforeEach(() => { + renderWithProviders() + }) + + it('should renders question input field', () => { + const questionInput = screen.getByLabelText('questionPage.question') + expect(questionInput).toBeInTheDocument() + }) + + it('should renders a open answer', () => { + const appSelect = screen.getByTestId('app-select') + + fireEvent.click(appSelect) + fireEvent.change(appSelect, { + target: { value: 'openAnswer' } + }) + + const multipleChoiceRadio = screen.getByText( + 'questionPage.questionType.openAnswer' + ) + expect(multipleChoiceRadio).toBeInTheDocument() + }) + + it('should renders a radio buttons for one answer', () => { + const appSelect = screen.getByTestId('app-select') + + fireEvent.click(appSelect) + fireEvent.change(appSelect, { + target: { value: 'oneAnswer' } + }) + + const multipleChoiceRadio = screen.getByText( + 'questionPage.questionType.oneAnswer' + ) + expect(multipleChoiceRadio).toBeInTheDocument() + }) + + it('should add a new one answer', () => { + const addNewOne = screen.getByText('questionPage.addNewOne') + + fireEvent.click(addNewOne) + const answer = screen.getByPlaceholderText('questionPage.writeYourAnswer') + + fireEvent.change(answer, { + target: { value: 'Changed answer' } + }) + + const inputValue = screen.getByDisplayValue('Changed answer') + + expect(inputValue).toBeInTheDocument() + }) + + it('should change question and answer input fields', () => { + const appSelect = screen.getByTestId('app-select') + + fireEvent.click(appSelect) + fireEvent.change(appSelect, { + target: { value: 'openAnswer' } + }) + + const questionInput = screen.getByLabelText('questionPage.question') + const answerInput = screen.getByLabelText('questionPage.answer') + + fireEvent.change(questionInput, { + target: { value: 'New question' } + }) + + fireEvent.change(answerInput, { + target: { value: 'New answer' } + }) + + const questionValue = screen.getByDisplayValue('New question') + const answerValue = screen.getByDisplayValue('New answer') + + expect(questionValue).toBeInTheDocument() + expect(answerValue).toBeInTheDocument() + }) + + it('should update answer.isCorrect for single choice questions', () => { + const appSelect = screen.getByTestId('app-select') + fireEvent.click(appSelect) + fireEvent.change(appSelect, { + target: { value: 'singleChoice' } + }) + + const addNewOne = screen.getByText('questionPage.addNewOne') + fireEvent.click(addNewOne) + + const firstAnswerCheckbox = screen.getByRole('checkbox', { name: '' }) + + fireEvent.click(firstAnswerCheckbox) + + expect(firstAnswerCheckbox).toBeChecked() + + const otherAnswerCheckboxes = screen.getAllByRole('checkbox', { name: '' }) + otherAnswerCheckboxes.forEach((checkbox, index) => { + if (index !== 0) { + expect(checkbox).not.toBeChecked() + } + }) + }) + + it('should update answer.isCorrect for single choice questions', () => { + const appSelect = screen.getByTestId('app-select') + fireEvent.click(appSelect) + fireEvent.change(appSelect, { + target: { value: 'oneAnswer' } + }) + + const addNewOne = screen.getByText('questionPage.addNewOne') + fireEvent.click(addNewOne) + + const answer = screen.getByPlaceholderText('questionPage.writeYourAnswer') + + fireEvent.change(answer, { + target: { value: 'New answer' } + }) + fireEvent.click(addNewOne) + + const firstAnswerRadio = screen.getAllByRole('radio')[0] + const secondAnswerRadio = screen.getAllByRole('radio')[1] + + fireEvent.click(firstAnswerRadio) + fireEvent.click(secondAnswerRadio) + + expect(firstAnswerRadio).not.toBeChecked() + expect(secondAnswerRadio).toBeChecked() + }) + + it('should delete a radio button', () => { + const addNewOne = screen.getByText('questionPage.addNewOne') + fireEvent.click(addNewOne) + + const answer = screen.getByPlaceholderText('questionPage.writeYourAnswer') + + fireEvent.change(answer, { + target: { value: 'New answer' } + }) + + fireEvent.click(addNewOne) + const deleteButtons = screen.getAllByTestId('CloseIcon') + + fireEvent.click(deleteButtons[0]) + + const remainingAnswers = screen.getAllByRole('checkbox') + expect(remainingAnswers.length - 1).toBe(1) + }) +})