diff --git a/src/constants/translations/en/course.json b/src/constants/translations/en/course.json new file mode 100644 index 000000000..cfa4e7b44 --- /dev/null +++ b/src/constants/translations/en/course.json @@ -0,0 +1,20 @@ +{ + "addSectionBtn": "Add Section", + "courseSection": { + "defaultNewTitle": "Section 1", + "defaultNewDescription": "Description...", + "addResourceBtn": "Add Resource", + "resourcesMenu": { + "lessonMenuItem": "Lesson", + "quizMenuItem": "Quiz", + "attachmentMenuItem": "Attachment" + }, + "sectionMenu": { + "deleteSection": "Delete" + } + }, + "errorMessages": { + "title": "Please provide a title", + "description": "Please provide a description" + } +} \ No newline at end of file diff --git a/src/constants/translations/en/index.ts b/src/constants/translations/en/index.ts index 4ce9d83bc..3bd0bc331 100644 --- a/src/constants/translations/en/index.ts +++ b/src/constants/translations/en/index.ts @@ -36,6 +36,7 @@ import myCoursesPage from './my-courses-page.json' import chatPage from './chat.json' import lesson from './lesson.json' import questionPage from './question-page.json' +import course from './course.json' const en = { translations: { @@ -76,7 +77,8 @@ const en = { myCoursesPage, chatPage, lesson, - questionPage + questionPage, + course } } diff --git a/src/constants/translations/ua/course.json b/src/constants/translations/ua/course.json new file mode 100644 index 000000000..1c44bc8f8 --- /dev/null +++ b/src/constants/translations/ua/course.json @@ -0,0 +1,20 @@ +{ + "addSectionBtn": "Новий курс", + "courseSection": { + "defaultNewTitle": "Розділ 1", + "defaultNewDescription": "Опис ...", + "addResourceBtn": "Додати матеріал", + "resourcesMenu": { + "lessonMenuItem": "Урок", + "quizMenuItem": "Тест", + "attachmentMenuItem": "Вкладення" + }, + "sectionMenu": { + "deleteSection": "Видалити" + } + }, + "errorMessages": { + "title": "Будь ласка, вкажіть заголовок.", + "description": "Будь ласка, надайте опис." + } +} \ No newline at end of file diff --git a/src/constants/translations/ua/index.ts b/src/constants/translations/ua/index.ts index ae6a736d5..19b573bd7 100644 --- a/src/constants/translations/ua/index.ts +++ b/src/constants/translations/ua/index.ts @@ -32,6 +32,7 @@ import admin from './admin.json' import cookiePolicyPage from './cookie-policy-page.json' import guestHomePage from './guest-home-page.json' import table from './table.json' +import course from './course.json' const ua = { translations: { @@ -68,7 +69,8 @@ const ua = { admin, cookiePolicyPage, guestHomePage, - table + table, + course } } diff --git a/src/containers/course-section/CourseSectionContainer.constants.tsx b/src/containers/course-section/CourseSectionContainer.constants.tsx new file mode 100644 index 000000000..4160a52b0 --- /dev/null +++ b/src/containers/course-section/CourseSectionContainer.constants.tsx @@ -0,0 +1,4 @@ +export const menuTypes = { + resourcesMenu: 'resources', + sectionMenu: 'section' +} diff --git a/src/containers/course-section/CourseSectionContainer.styles.ts b/src/containers/course-section/CourseSectionContainer.styles.ts new file mode 100644 index 000000000..7385ce2f1 --- /dev/null +++ b/src/containers/course-section/CourseSectionContainer.styles.ts @@ -0,0 +1,62 @@ +import { TypographyVariantEnum } from '~/types' + +export const styles = { + root: { + backgroundColor: 'basic.white', + borderRadius: '6px', + p: '24px' + }, + header: { + display: 'flex', + justifyContent: 'space-between', + alignItems: 'baseline' + }, + dragIconWrapper: { + display: 'flex', + justifyContent: 'center' + }, + dragIcon: { + fontSize: '30px', + transform: 'rotate(90deg)', + color: 'primary.400', + cursor: 'pointer' + }, + input: { + style: { padding: 0, margin: 0 } + }, + titleInput: { + disableUnderline: true, + style: { + marginTop: 0, + fontSize: '20px', + maxHeight: '20px', + fontWeight: 500 + } + }, + headerIconWrapper: { + marginRight: '20px' + }, + headerIcon: { + fontSize: '24px' + }, + descriptionInput: { + style: { + marginTop: 0 + }, + disableUnderline: true + }, + titleLabel: { + shrink: false, + sx: { typography: TypographyVariantEnum.H6, top: -23 } + }, + descriptionLabel: { + sx: { typography: TypographyVariantEnum.Body1, top: -20 }, + shrink: false + }, + menuItem: { + minWidth: '300px', + pl: '30px', + py: '15px', + typography: TypographyVariantEnum.MidTitle + } +} diff --git a/src/containers/course-section/CourseSectionContainer.tsx b/src/containers/course-section/CourseSectionContainer.tsx new file mode 100644 index 000000000..45e511616 --- /dev/null +++ b/src/containers/course-section/CourseSectionContainer.tsx @@ -0,0 +1,187 @@ +import { useState, ChangeEvent, FC, Dispatch, SetStateAction } from 'react' +import { useTranslation } from 'react-i18next' +import { MenuItem } from '@mui/material' +import Box from '@mui/material/Box' +import DragIndicatorIcon from '@mui/icons-material/DragIndicator' +import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp' +import IconButton from '@mui/material/IconButton' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import AddIcon from '@mui/icons-material/Add' +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' + +import AppTextField from '~/components/app-text-field/AppTextField' +import AppButton from '~/components/app-button/AppButton' + +import useMenu from '~/hooks/use-menu' + +import { styles } from '~/containers/course-section/CourseSectionContainer.styles' +import { menuTypes } from '~/containers/course-section/CourseSectionContainer.constants' +import { + TextFieldVariantEnum, + SizeEnum, + ColorEnum, + ButtonVariantEnum, + CourseSection +} from '~/types' + +interface SectionProps { + sectionData: CourseSection + setSectionsItems: Dispatch> +} + +type openModalFunc = () => void + +const CourseSectionContainer: FC = ({ + sectionData, + setSectionsItems +}) => { + const { t } = useTranslation() + const { openMenu, renderMenu, closeMenu } = useMenu() + + const [activeMenu, setActiveMenu] = useState('') + const [isVisible, setIsVisible] = useState(true) + const [titleInput, setTitleInput] = useState(sectionData.title) + const [descriptionInput, setDescriptionInput] = useState( + sectionData.description + ) + + const onShowHide = () => { + setIsVisible((isVisible) => !isVisible) + } + + const onAction = (actionFunc: openModalFunc) => { + actionFunc() + } + + const onTitleInputChange = (event: ChangeEvent) => { + setTitleInput(event.target.value) + } + + const onDescriptionInputChange = (event: ChangeEvent) => { + setDescriptionInput(event.target.value) + } + + const onDeleteSection = () => { + setSectionsItems((prev) => { + return prev.filter((item) => item.id !== sectionData.id) + }) + } + + const sectionActions = [ + { + id: 1, + label: {t('course.courseSection.sectionMenu.deleteSection')}, + func: onDeleteSection + } + ] + + const sectionMenuItems = sectionActions.map(({ label, func, id }) => ( + + {label} + + )) + + const addResourceActions = [ + { + id: 1, + label: ( + {t('course.courseSection.resourcesMenu.lessonMenuItem')} + ), + func: closeMenu + }, + { + id: 2, + label: {t('course.courseSection.resourcesMenu.quizMenuItem')}, + func: closeMenu + }, + { + id: 3, + label: ( + {t('course.courseSection.resourcesMenu.attachmentMenuItem')} + ), + func: closeMenu + } + ] + + const resourcesMenuItems = addResourceActions.map(({ label, func, id }) => ( + onAction(func)} sx={styles.menuItem}> + {label} + + )) + + return ( + + + + + + + {isVisible ? ( + + ) : ( + + )} + + + { + setActiveMenu(menuTypes.sectionMenu) + openMenu(event) + }} + > + + + {activeMenu === menuTypes.sectionMenu && renderMenu(sectionMenuItems)} + + {isVisible && ( + + + } + onClick={(event) => { + setActiveMenu(menuTypes.resourcesMenu) + openMenu(event) + }} + size={SizeEnum.Large} + startIcon={} + variant={ButtonVariantEnum.Contained} + > + {t('course.courseSection.addResourceBtn')} + + {activeMenu === menuTypes.resourcesMenu && + renderMenu(resourcesMenuItems)} + + )} + + ) +} + +export default CourseSectionContainer diff --git a/src/containers/course-sections-list/CourseSectionsList.styles.ts b/src/containers/course-sections-list/CourseSectionsList.styles.ts new file mode 100644 index 000000000..8a7d89449 --- /dev/null +++ b/src/containers/course-sections-list/CourseSectionsList.styles.ts @@ -0,0 +1,16 @@ +import palette from '~/styles/app-theme/app.pallete' + +export const styles = { + root: { + marginTop: '32px' + }, + section: (isDragging: boolean) => ({ + mb: '32px', + backgroundColor: 'basic.white', + borderRadius: '6px', + ...(isDragging && { + boxShadow: `0px 3px 16px 2px ${palette.primary[300]}`, + border: `2px solid ${palette.primary[300]}` + }) + }) +} diff --git a/src/containers/course-sections-list/CourseSectionsList.tsx b/src/containers/course-sections-list/CourseSectionsList.tsx new file mode 100644 index 000000000..c2a02d752 --- /dev/null +++ b/src/containers/course-sections-list/CourseSectionsList.tsx @@ -0,0 +1,88 @@ +import { FC, Dispatch, SetStateAction } from 'react' +import { + DragDropContext, + Droppable, + Draggable, + DraggableProvided, + DraggableStateSnapshot, + DropResult, + DroppableProvided +} from 'react-beautiful-dnd' +import Box from '@mui/material/Box' + +import CourseSectionContainer from '~/containers/course-section/CourseSectionContainer' + +import { styles } from '~/containers/course-sections-list/CourseSectionsList.styles' + +import { CourseSection } from '~/types' + +interface CourseSectionsListProps { + items: CourseSection[] + setSectionsItems: Dispatch> +} + +const CourseSectionsList: FC = ({ + items, + setSectionsItems +}) => { + const reorder = ( + list: CourseSection[], + startIndex: number, + endIndex: number + ): CourseSection[] => { + const result = Array.from(list) + const [removed] = result.splice(startIndex, 1) + result.splice(endIndex, 0, removed) + + return result + } + + const onDragEnd = (result: DropResult) => { + if (!result.destination) { + return + } + + const reorderedItems = reorder( + items, + result.source.index, + result.destination.index + ) + + setSectionsItems(reorderedItems) + } + + const sectionsList = items.map((item, i) => ( + + {(provided: DraggableProvided, snapshot: DraggableStateSnapshot) => ( + + + + )} + + )) + + return ( + + + + {(provided: DroppableProvided) => ( + + {sectionsList} + {provided.placeholder} + + )} + + + + ) +} + +export default CourseSectionsList diff --git a/src/pages/create-course/CreateCourse.constants.tsx b/src/pages/create-course/CreateCourse.constants.tsx new file mode 100644 index 000000000..9e3ddfa4f --- /dev/null +++ b/src/pages/create-course/CreateCourse.constants.tsx @@ -0,0 +1,6 @@ +export const sectionInitialData = { + id: 0, + title: '', + description: '', + resources: [] +} diff --git a/src/pages/create-course/CreateCourse.styles.ts b/src/pages/create-course/CreateCourse.styles.ts index 65c9e072c..081b2ea3c 100644 --- a/src/pages/create-course/CreateCourse.styles.ts +++ b/src/pages/create-course/CreateCourse.styles.ts @@ -4,5 +4,11 @@ export const styles = { justifyContent: 'flex-end', gap: '24px', mt: '32px' + }, + functionalButton: { + display: 'flex', + '& button': { + width: '100%' + } } } diff --git a/src/pages/create-course/CreateCourse.tsx b/src/pages/create-course/CreateCourse.tsx index abd5a9dde..4c2da18f1 100644 --- a/src/pages/create-course/CreateCourse.tsx +++ b/src/pages/create-course/CreateCourse.tsx @@ -1,23 +1,64 @@ +import { useState } from 'react' import { useNavigate } from 'react-router-dom' import { useTranslation } from 'react-i18next' import Box from '@mui/material/Box' +import AddIcon from '@mui/icons-material/Add' import PageWrapper from '~/components/page-wrapper/PageWrapper' import AppButton from '~/components/app-button/AppButton' +import CourseSectionsList from '~/containers/course-sections-list/CourseSectionsList' + +import { sectionInitialData } from '~/pages/create-course/CreateCourse.constants' import AddCourseBanner from '~/containers/add-course-banner/AddCourseBanner' import { authRoutes } from '~/router/constants/authRoutes' -import { ButtonTypeEnum, ButtonVariantEnum } from '~/types' +import { + ButtonTypeEnum, + ButtonVariantEnum, + SizeEnum, + CourseSection +} from '~/types' import { styles } from '~/pages/create-course/CreateCourse.styles' const CreateCourse = () => { const { t } = useTranslation() const navigate = useNavigate() + + const [sectionsItems, setSectionsItems] = useState([]) + + const createNewSection = () => { + const newSectionData = { ...sectionInitialData } + newSectionData.id = sectionsItems.length + setSectionsItems([...sectionsItems, newSectionData]) + } + + if (sectionsItems.length === 0) { + createNewSection() + } + + const onAddSectionClick = () => { + createNewSection() + } + const formData = new FormData() return ( + + + + + {t('course.addSectionBtn')} + + navigate(authRoutes.myCourses.root.path)} diff --git a/src/types/course/course.index.ts b/src/types/course/course.index.ts new file mode 100644 index 000000000..6701903ed --- /dev/null +++ b/src/types/course/course.index.ts @@ -0,0 +1 @@ +export * from '~/types/course/interfaces/course.interface' diff --git a/src/types/course/interfaces/course.interface.ts b/src/types/course/interfaces/course.interface.ts new file mode 100644 index 000000000..f16a426e8 --- /dev/null +++ b/src/types/course/interfaces/course.interface.ts @@ -0,0 +1,15 @@ +import { CommonEntityFields, Lesson, Quiz, Attachment, Category } from '~/types' + +export interface Course extends CommonEntityFields { + title: string + description: string + sections?: CourseSection[] + category: Category | null +} + +export interface CourseSection { + id: number + title: string + description: string + resources: (Lesson | Quiz | Attachment)[] +} diff --git a/src/types/index.ts b/src/types/index.ts index 51fb6bd75..03e352bdb 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -23,3 +23,4 @@ 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' +export * from '~/types/course/course.index' diff --git a/tests/unit/containers/course-section/CourseSectionContainer.spec.jsx b/tests/unit/containers/course-section/CourseSectionContainer.spec.jsx new file mode 100644 index 000000000..349635834 --- /dev/null +++ b/tests/unit/containers/course-section/CourseSectionContainer.spec.jsx @@ -0,0 +1,61 @@ +import { renderWithProviders } from '~tests/test-utils' +import { screen, fireEvent } from '@testing-library/react' + +import CourseSectionContainer from '~/containers/course-section/CourseSectionContainer' + +const mockedSectionData = { + section_id: 1, + title: '', + description: '', + resources: [] +} + +describe('CourseSectionContainer tests', () => { + beforeEach(() => { + renderWithProviders( + + ) + }) + + it('should render inputs for title and description', () => { + const titleInput = screen.getByText('course.courseSection.defaultNewTitle') + const labelInput = screen.getByText( + 'course.courseSection.defaultNewDescription' + ) + + expect(titleInput).toBeInTheDocument() + expect(labelInput).toBeInTheDocument() + }) + + it('should render menu button and menu', () => { + const addResourcesBtn = screen.getByText( + 'course.courseSection.addResourceBtn' + ) + fireEvent.click(addResourcesBtn) + const menuList = screen.getByRole('menu') + + expect(menuList).toBeInTheDocument() + }) + + it('should close menu after click', () => { + const addResourcesBtn = screen.getByText( + 'course.courseSection.addResourceBtn' + ) + fireEvent.click(addResourcesBtn) + const menuListItem = screen.getAllByRole('menuitem')[0] + fireEvent.click(menuListItem) + + expect(menuListItem).not.toBeVisible() + }) + + it('should hide section content', () => { + const addResourcesBtn = screen.getByText( + 'course.courseSection.addResourceBtn' + ) + const hideBtn = screen.getAllByRole('button')[0] + + fireEvent.click(hideBtn) + + expect(addResourcesBtn).not.toBeVisible() + }) +}) diff --git a/tests/unit/pages/create-course/CreateCourse.spec.jsx b/tests/unit/pages/create-course/CreateCourse.spec.jsx index 6c3bee013..03c47c17b 100644 --- a/tests/unit/pages/create-course/CreateCourse.spec.jsx +++ b/tests/unit/pages/create-course/CreateCourse.spec.jsx @@ -30,4 +30,10 @@ describe('CreateCourse', () => { expect(mockedNavigate).toHaveBeenCalled() }) + + it('should render add section button', () => { + const addSectionButton = screen.getByText('course.addSectionBtn') + + expect(addSectionButton).toBeInTheDocument() + }) })