From 68cbb29e16ab48bf11b6cfb93f87c120e729de4e Mon Sep 17 00:00:00 2001 From: Olenka Hryk Date: Wed, 28 Aug 2024 17:14:10 +0300 Subject: [PATCH] Added event handlers and a Redux store for cooperation (#2061) * Added event handlers and a Redux store for cooperation * skip tests * Deleted context and added updateAvailabilityStatus for all resources * skip test * Rewrote spec for ProfileInfo.jsx container #2073 * Combined lessons, quizzes, attachments into 'activities' array in cooperation store #2085 (#2168) * Combined lessons, quizzes, attachments into 'activities' array in cooperation store #2085 * Rewrote spec for CooperationActivitiesList.tsx container #2075 * Added spec for "CourseSectionContainer" container #2071 * Added spec for "CourseSectionsList" container #2072 * Refactored: activities -> resources in Cooperation * Refactored Cooperation & Course to apply requested changes * Fixed tests after Cooperation & Course updates * Fixed date serialization and mask input issues in DatePicker component (#2303) * Replaced functionality of create course in order to use new handlers #2070 * Rewrote spec for CreateCourse.tsx page #2323 * Added error message on saving section without title or description in cooperation #2327 (#2334) * Added error message on saving section without title or description in cooperation #2327 * Rewrote spec for CooperationActivities container * Refactored CooperationActivities to utilize useAxios * Added event handlers and a Redux store for cooperation * skip tests * Deleted context and added updateAvailabilityStatus for all resources * skip test * Rewrote spec for ProfileInfo.jsx container #2073 * Combined lessons, quizzes, attachments into 'activities' array in cooperation store #2085 (#2168) * Combined lessons, quizzes, attachments into 'activities' array in cooperation store #2085 * Rewrote spec for CooperationActivitiesList.tsx container #2075 * Added spec for "CourseSectionContainer" container #2071 * Added spec for "CourseSectionsList" container #2072 * Fixed failing test in CreateCourse page * Integrated the "CheckboxWithTooltip" component into the modals in the Course & Cooperation * Added the possibility to add a resource to a Cooperation in two ways: by duplicating or linking * Added spec for cooperationsSlice & improved coverage CheckboxWithTooltip, CourseSectionContainer, CooperationActivitiesList * Added the possibility to add a resource to a Course in two ways: by duplicating or linking * Rewrote CreateCourse spec to improve code coverage * Updated icon display based on resource addition method (link & copy) #2370 * Disabled already linked resources in Course/Cooperation in choose resources modals #2367 * Fixed drag-and-drop functionality and unique ID generation * Resolved BSONTypeError when adding duplicate resources to Course/Cooperation * Created utility function isValidUUID and spec * Updated coverage of tests * Deleted problem test in spec on Create Course page --- .../CheckboxWithTooltip.tsx | 10 +- .../CooperationSectionView.tsx | 30 +- .../enhanced-table/EnhancedTable.tsx | 52 +- .../EnhancedTableRow.styles.ts | 8 +- .../enhanced-table-row/EnhancedTableRow.tsx | 91 ++- .../translations/en/my-resources-page.json | 18 +- .../translations/uk/my-resources-page.json | 11 +- src/containers/add-resources/AddResources.tsx | 116 ++-- .../CooperationActivitiesView.style.ts | 0 .../CooperationActivitiesView.tsx | 24 +- .../CooperationActivities.tsx | 98 ++- .../CourseSectionContainer.constants.tsx | 14 +- .../course-section/CourseSectionContainer.tsx | 288 ++++---- .../resource-item/ResourceItem.styles.ts | 5 + .../resource-item/ResourceItem.tsx | 112 ++-- .../resources-list/ResourcesList.tsx | 45 +- .../CourseSectionsList.tsx | 130 ++-- .../CooperationActivitiesList.constants.ts | 11 - .../CooperationActivitiesList.tsx | 152 +++-- .../CooperationDetails.constants.tsx | 1 + .../CooperationDetails.tsx | 24 +- .../CooperationContainer.constants.tsx | 8 +- .../CooperationContainer.tsx | 4 +- .../EmptyCooperationTutorControls.tsx | 4 +- .../my-offers-container/MyOffersContainer.tsx | 22 +- .../my-quizzes/QuizzesContainer.tsx | 4 +- .../CreateOrEditQuizContainer.constants.ts | 3 +- .../CreateOrEditQuizContainer.tsx | 11 +- .../AddResourceModal.styles.ts | 7 +- .../add-resource-modal/AddResourceModal.tsx | 87 ++- .../AttachmentsContainer.tsx | 4 +- .../CategoriesContainer.tsx | 4 +- .../lessons-container/LessonsContainer.tsx | 4 +- .../QuestionsContainer.tsx | 4 +- .../resources-availability-context.tsx | 53 -- src/hooks/table/use-select.tsx | 4 +- .../create-course/CreateCourse.constants.tsx | 7 +- src/pages/create-course/CreateCourse.tsx | 293 +++++++-- .../CreateOrEditLesson.constants.tsx | 4 +- .../CreateOrEditLesson.tsx | 2 +- .../LessonDetails.constants.tsx | 4 +- src/redux/features/cooperationsSlice.ts | 213 +++++- .../common/interfaces/common.interfaces.ts | 77 ++- .../course/interfaces/course.interface.ts | 15 +- src/types/course/types/course.types.ts | 13 +- .../my-resources/enum/myResources.enum.ts | 9 +- .../interfaces/myResources.interface.ts | 8 +- src/utils/helper-functions.tsx | 2 +- src/utils/validations/isValidUUID.js | 6 + .../CheckboxWithTooltip.spec.jsx | 15 +- .../CooperationSectionView.spec.jsx | 13 +- .../add-attachments/AddAttachments.spec.jsx | 3 +- .../add-lessons/AddLessons.spec.jsx | 3 +- .../add-questions/AddQuestions.spec.jsx | 3 +- .../add-quizzes/AddQuizzes.spec.jsx | 3 +- .../CooperationActivities.spec.jsx | 14 - .../AddCourseTemplateModal.spec.jsx | 0 .../ CooperationActivitiesView.spec.jsx | 11 +- .../CooperationActivities.spec.jsx | 202 ++++++ .../CourseSectionContainer.spec.constants.js | 71 ++ .../CourseSectionContainer.spec.jsx | 469 +++++++++---- .../ResourceItem.spec.constants.js | 51 ++ .../resource-item/ResourceItem.spec.jsx | 222 ++++++- .../resources-list/ResourcesList.spec.jsx | 6 +- .../CourseSectionsList.spec.jsx | 130 ++-- ...ooperationActivitiesList.spec.constants.js | 50 ++ .../CooperationActivitiesList.spec.jsx | 326 +++++++-- .../course-toolbar/CourseToolbar.spec.jsx | 4 +- .../tutor-profile/ProfileInfo.spec.jsx | 133 ++-- .../CreateCourse.spec.constants.js | 213 ++++++ .../pages/create-course/CreateCourse.spec.jsx | 616 +++++++++++++++--- .../CreateOrEditLesson.spec.jsx | 26 +- tests/unit/pages/quiz/Quiz.spec.jsx | 7 +- tests/unit/redux/cooperationsSlice.spec.js | 278 ++++++++ .../utils/validations/isValidUUID.spec.js | 27 + 75 files changed, 3779 insertions(+), 1233 deletions(-) rename src/containers/cooperation-details/{cooperetion-activities-view => cooperation-activities-view}/CooperationActivitiesView.style.ts (100%) rename src/containers/cooperation-details/{cooperetion-activities-view => cooperation-activities-view}/CooperationActivitiesView.tsx (82%) delete mode 100644 src/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.constants.ts delete mode 100644 src/context/resources-availability-context.tsx create mode 100644 src/utils/validations/isValidUUID.js delete mode 100644 tests/unit/containers/cooperation-details/CooperationActivities.spec.jsx rename tests/unit/containers/cooperation-details/{ => add-course-modal-modal}/AddCourseTemplateModal.spec.jsx (100%) rename tests/unit/containers/cooperation-details/{ => cooperation-activities-view}/ CooperationActivitiesView.spec.jsx (85%) create mode 100644 tests/unit/containers/cooperation-details/cooperation-activities/CooperationActivities.spec.jsx create mode 100644 tests/unit/containers/course-section/CourseSectionContainer.spec.constants.js create mode 100644 tests/unit/containers/course-section/resource-item/ResourceItem.spec.constants.js create mode 100644 tests/unit/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.spec.constants.js create mode 100644 tests/unit/pages/create-course/CreateCourse.spec.constants.js create mode 100644 tests/unit/redux/cooperationsSlice.spec.js create mode 100644 tests/unit/utils/validations/isValidUUID.spec.js diff --git a/src/components/checkbox-with-tooltip/CheckboxWithTooltip.tsx b/src/components/checkbox-with-tooltip/CheckboxWithTooltip.tsx index 49df861ff..53750ed4d 100644 --- a/src/components/checkbox-with-tooltip/CheckboxWithTooltip.tsx +++ b/src/components/checkbox-with-tooltip/CheckboxWithTooltip.tsx @@ -1,4 +1,4 @@ -import { ReactNode } from 'react' +import { ReactNode, useCallback } from 'react' import { Box, @@ -15,13 +15,20 @@ import { styles } from './CheckboxWithTooltip.styles' interface CheckboxWithTooltipProps extends CheckboxProps { label: ReactNode tooltipTitle: ReactNode + onChecked?: (value: boolean) => void } const CheckboxWithTooltip = ({ label, tooltipTitle, + onChecked, ...props }: CheckboxWithTooltipProps) => { + const onChangeHandler = useCallback( + (_: React.SyntheticEvent, checked: boolean) => onChecked?.(checked), + [onChecked] + ) + return ( } label={label} + onChange={onChangeHandler} sx={styles.label} /> = ({ - item, - id -}) => { - const [isVisible, setIsVisible] = useState(true) +const CooperationSectionView: FC = ({ item }) => { const { t } = useTranslation() + const [isVisible, setIsVisible] = useState(true) const resources = useMemo( () => - item.activities?.map((activity: Activities) => ( + item.resources?.map(({ resource, resourceType }) => ( )), - [item.activities] + [item.resources] ) return ( @@ -44,7 +40,7 @@ const CooperationSectionView: FC = ({ setIsVisible={setIsVisible} /> {isVisible && ( - + extends Omit { columns: TableColumn[] isSelection?: boolean rowActions?: TableRowAction[] + onRowClick?: (item: I) => void select?: TableSelect filter?: TableFilter sort: TableSort rowsPerPage?: number data: TableData - onRowClick?: (item: I) => void + disableInitialSelectedRows?: boolean emptyTableKey?: string selectedRows?: I[] + initialSelectedRows?: I[] style?: { root?: SxProps tableContainer?: SxProps @@ -53,27 +56,46 @@ const EnhancedTable = ({ sort, rowsPerPage, data, + disableInitialSelectedRows = false, emptyTableKey = 'table.noExactMatches', selectedRows = [], + initialSelectedRows = [], style = {}, ...props }: EnhancedTableProps) => { const { t } = useTranslation() const { items, loading, getData } = data - const rows = items.map((item) => ( - - )) + const rows = useMemo( + () => + items.map((item) => ( + + )), + [ + items, + columns, + initialSelectedRows, + disableInitialSelectedRows, + isSelection, + onRowClick, + getData, + rowActions, + select, + selectedRows + ] + ) const tableBody = ( ({ + row: (isSelected: boolean, isRowOnClick = false, isDisableRow = false) => ({ ...(isRowOnClick && { cursor: 'pointer' }), ...(isSelected && { background: palette.basic.grey }), '&:hover .addCategory': { visibility: 'visible' - } + }, + ...(isDisableRow && { + pointerEvents: 'none', + opacity: 0.5 + }) }) } diff --git a/src/components/enhanced-table/enhanced-table-row/EnhancedTableRow.tsx b/src/components/enhanced-table/enhanced-table-row/EnhancedTableRow.tsx index a5183afbd..27a011445 100644 --- a/src/components/enhanced-table/enhanced-table-row/EnhancedTableRow.tsx +++ b/src/components/enhanced-table/enhanced-table-row/EnhancedTableRow.tsx @@ -1,16 +1,16 @@ +import { ReactNode, useCallback, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' -import { ReactNode } from 'react' -import MoreVertIcon from '@mui/icons-material/MoreVert' import Checkbox from '@mui/material/Checkbox' +import MoreVertIcon from '@mui/icons-material/MoreVert' import IconButton from '@mui/material/IconButton' import MenuItem from '@mui/material/MenuItem' import TableCell from '@mui/material/TableCell' import TableRow from '@mui/material/TableRow' -import useMenu from '~/hooks/use-menu' import { styles } from '~/components/enhanced-table/enhanced-table-row/EnhancedTableRow.styles' +import useMenu from '~/hooks/use-menu' import { TableActionFunc, TableColumn, @@ -22,60 +22,89 @@ import { export interface EnhancedTableRowProps { columns: TableColumn[] isSelection?: boolean + isDisableRow?: boolean item: I onRowClick?: (item: I) => void refetchData?: () => void rowActions?: TableRowAction[] select?: TableSelect selectedRows: I[] + initialSelectedRows?: I[] +} + +interface AdditionalProps { + t: ReturnType['t'] + navigate: ReturnType } const EnhancedTableRow = ({ columns, isSelection, + isDisableRow = false, item, + onRowClick, refetchData, rowActions, - onRowClick, select = {} as TableSelect, - selectedRows + selectedRows, + initialSelectedRows = [] }: EnhancedTableRowProps) => { const { t } = useTranslation() const navigate = useNavigate() const { openMenu, renderMenu, closeMenu } = useMenu() const { isSelected, handleSelectClick } = select - const onAction = async (actionFunc: TableActionFunc) => { - closeMenu() - await actionFunc(item._id) - refetchData && refetchData() - } + const onAction = useCallback( + async (actionFunc: TableActionFunc) => { + closeMenu() + await actionFunc(item._id) + refetchData && refetchData() + }, + [closeMenu, item._id, refetchData] + ) - const additionalProps = { t, navigate } + const additionalProps = useMemo(() => ({ t, navigate }), [t, navigate]) - const tableCells = columns.map(({ field, label, calculatedCellValue }) => { - let propValue: string | ReactNode - if (calculatedCellValue) { - propValue = calculatedCellValue(item, additionalProps) - } else { - propValue = item[field as keyof I]?.toString() - } + const renderTableCell = useCallback( + ( + field: keyof I, + label: string, + calculatedCellValue?: (item: I, props: AdditionalProps) => ReactNode + ) => { + const propValue = calculatedCellValue + ? calculatedCellValue(item, additionalProps) + : item[field]?.toString() + return {propValue} + }, + [additionalProps, item] + ) - return {propValue} - }) + const tableCells = useMemo( + () => + columns.map(({ field, label, calculatedCellValue }) => + renderTableCell(field as keyof I, label, calculatedCellValue) + ), + [columns, renderTableCell] + ) - const menuItems = rowActions?.map(({ label, func }) => ( - void onAction(func)}> - {label} - - )) + const menuItems = useMemo( + () => + rowActions?.map(({ label, func }) => ( + void onAction(func)}> + {label} + + )), + [rowActions, onAction] + ) + + const handleRowClick = () => onRowClick && onRowClick(item) - const handleRowClick = () => (onRowClick ? onRowClick(item) : null) + const isRowSelected = !!( + onRowClick && selectedRows.find((row) => row._id === item._id) + ) - const isRowSelected = - onRowClick && - selectedRows.length && - selectedRows.find((row) => row._id === item._id) + const isInitialSelected = + isDisableRow && !!initialSelectedRows?.find((row) => row._id === item._id) return ( ({ key={item._id} onClick={handleRowClick} selected={isSelection && isSelected(item._id)} - sx={styles.row(!!isRowSelected, !!onRowClick)} + sx={styles.row(isRowSelected, !!onRowClick, isInitialSelected)} > {isSelection && ( diff --git a/src/constants/translations/en/my-resources-page.json b/src/constants/translations/en/my-resources-page.json index 6d2568ced..5269fa106 100644 --- a/src/constants/translations/en/my-resources-page.json +++ b/src/constants/translations/en/my-resources-page.json @@ -16,7 +16,7 @@ "lastUpdates": "Last updates", "emptyItems": "You have no lessons yet", "confirmDeletionTitle": "Do you confirm lesson deletion?", - "successDeletion": "Lesson was deleted successfully", + "successDeletion": "Lesson was deleted successfully", "searchInput": "Search a lesson" }, "attachments": { @@ -35,7 +35,6 @@ "attachmentName": "Attachment name", "attachmentCategory": "Attachment category", "searchInput": "Search an attachment" - }, "quizzes": { "addBtn": "New quiz", @@ -49,7 +48,7 @@ "confirmDeletionTitle": "Do you confirm quiz deletion?", "successDeletion": "Quiz was deleted successfully", "editQuestion": "Edit question", - "successEditedQuiz": "Quiz was successtully edited", + "successEditedQuiz": "Quiz was successfully edited", "successAddedQuiz": "Quiz was successfully created", "defaultNewTitle": "Untitled", "defaultNewDescription": "Description...", @@ -57,7 +56,7 @@ "savePreviousQuestion": "Please save the previous question", "addQuestion": "Add question", "settingsPointsAndAnswers": "Settings: Points and answers", - "searchInput": "Search a quizz", + "searchInput": "Search a quiz", "pointValues": "Point values", "pointValuesDesc": "Respondents can see total points and points received for each question", "scoredUnscoredResponses": "Scored / Unscored responses", @@ -104,5 +103,14 @@ "categoryAlreadyExistsError": "Category with the same name already exists", "searchInput": "Search a category" }, - "confirmDeletionMessage": "This action is permanent and will remove all related content. Please review your decision before proceeding." + "confirmDeletionMessage": "This action is permanent and will remove all related content. Please review your decision before proceeding.", + "resourceDuplication": { + "resource": { + "lessons": "lesson", + "quizzes": "quiz", + "attachments": "attachment" + }, + "description": "Make a copy of the selected {{resource}}.", + "tooltip": "By making a copy of selected resources, any changes you'll make to the resource will not influence the resource in your other cooperation/courses." + } } diff --git a/src/constants/translations/uk/my-resources-page.json b/src/constants/translations/uk/my-resources-page.json index 22959dae0..6925699c6 100644 --- a/src/constants/translations/uk/my-resources-page.json +++ b/src/constants/translations/uk/my-resources-page.json @@ -105,5 +105,14 @@ "categoryAlreadyExistsError": "Категорія з такою назвою вже існує", "searchInput": "Пошук категорії" }, - "confirmDeletionMessage": "Ця дія є остаточною, та призведе до видалення всього пов’язаного вмісту. Перш ніж продовжити, перегляньте своє рішення." + "confirmDeletionMessage": "Ця дія є остаточною, та призведе до видалення всього пов’язаного вмісту. Перш ніж продовжити, перегляньте своє рішення.", + "resourceDuplication": { + "resource": { + "lessons": "уроку", + "quizzes": "тесту", + "attachments": "вкладення" + }, + "description": "Створити копію вибраного {{resource}}.", + "tooltip": "Створивши копію вибраних ресурсів, будь-які зміни, які ви внесете до ресурсу, не вплинуть на ресурс у ваших інших співпрацях/курсах." + } } diff --git a/src/containers/add-resources/AddResources.tsx b/src/containers/add-resources/AddResources.tsx index 2aa283275..ebb695c96 100644 --- a/src/containers/add-resources/AddResources.tsx +++ b/src/containers/add-resources/AddResources.tsx @@ -1,17 +1,18 @@ import { useCallback, useState } from 'react' import { useAppDispatch } from '~/hooks/use-redux' -import { useModalContext } from '~/context/modal-context' import useSelect from '~/hooks/table/use-select' import useSort from '~/hooks/table/use-sort' import useAxios from '~/hooks/use-axios' import useBreakpoints from '~/hooks/use-breakpoints' -import AddResourceModal from '~/containers/my-resources/add-resource-modal/AddResourceModal' - -import { initialSort } from '~/containers/add-resources/AddResources.constants' +import { useModalContext } from '~/context/modal-context' +import { openAlert } from '~/redux/features/snackbarSlice' import { defaultResponses, snackbarVariants } from '~/constants' -import { ajustColumns } from '~/utils/helper-functions' +import { initialSort } from '~/containers/add-resources/AddResources.constants' +import AddResourceModal from '~/containers/my-resources/add-resource-modal/AddResourceModal' +import { adjustColumns } from '~/utils/helper-functions' +import { getErrorKey } from '~/utils/get-error-key' import { ErrorResponse, GetResourcesParams, @@ -20,40 +21,49 @@ import { TableColumn, RemoveColumnRules, Question, - ServiceFunction + ServiceFunction, + ResourcesTabsEnum } from '~/types' -import { openAlert } from '~/redux/features/snackbarSlice' -import { getErrorKey } from '~/utils/get-error-key' interface AddResourcesProps { - resources: T[] - onAddResources: (resource: T[]) => void - resourceType: string - requestService: ServiceFunction, GetResourcesParams> + resources?: T[] + onAddResources: (resource: T[], isDuplicate: boolean) => void + resourceTab: ResourcesTabsEnum columns: TableColumn[] removeColumnRules: RemoveColumnRules + requestService: ServiceFunction, GetResourcesParams> + showCheckboxWithTooltip?: boolean } const AddResources = ({ resources = [], onAddResources, - resourceType, - requestService, + resourceTab, columns, - removeColumnRules + removeColumnRules, + requestService, + showCheckboxWithTooltip = false }: AddResourcesProps) => { - const [selectedRows, setSelectedRows] = useState(resources) - const { closeModal } = useModalContext() const dispatch = useAppDispatch() const breakpoints = useBreakpoints() + const { closeModal } = useModalContext() + + const [selectedRows, setSelectedRows] = useState(resources) + const [initialSelectedRows, setInitialSelectedRows] = useState(resources) + const [isDuplicate, setIsDuplicate] = useState(false) + const initialSelect = resources.map((resource) => resource._id) - const select = useSelect({ initialSelect }) + const { clearSelected, ...select } = useSelect({ initialSelect }) const sortOptions = useSort({ initialSort }) const { sort } = sortOptions const { handleSelectClick } = select - const columnsToShow = ajustColumns(breakpoints, columns, removeColumnRules) + const columnsToShow = adjustColumns( + breakpoints, + columns, + removeColumnRules + ) const getMyResources = useCallback( () => requestService({ sort }), @@ -78,38 +88,53 @@ const AddResources = ({ onResponseError }) - const onRowClick = (item: T) => { - if (selectedRows.find((resource) => resource._id === item._id)) { + const onRowClick = useCallback( + (item: T) => { setSelectedRows((selectedRows) => - selectedRows.filter((resource) => resource._id !== item._id) + selectedRows.find((resource) => resource._id === item._id) + ? selectedRows.filter((resource) => resource._id !== item._id) + : [...selectedRows, item] ) - } else { - setSelectedRows((selectedRows) => [...selectedRows, item]) - } - handleSelectClick(item._id) - } + handleSelectClick(item._id) + }, + [handleSelectClick] + ) - const onAddItems = () => { - onAddResources(selectedRows) + const onAddItems = useCallback(() => { + onAddResources(selectedRows, isDuplicate) closeModal() - } + }, [selectedRows, isDuplicate, onAddResources, closeModal]) + + const onCreateResourceCopy = useCallback( + (value: boolean) => { + setIsDuplicate(value) + if (value) { + setSelectedRows([]) + setInitialSelectedRows([]) + clearSelected() + } else { + setSelectedRows(resources) + setInitialSelectedRows(resources) + select.setSelected(resources.map((item) => item._id)) + } + }, + [resources, clearSelected, select] + ) const getItems = useCallback( (inputValue: string, selectedCategories: string[]) => { return response.items.filter((item) => { - let titleMatch - if ('title' in item) { - titleMatch = item.title - .toLocaleLowerCase() - .includes(inputValue.toLocaleLowerCase()) - } else { - titleMatch = item.fileName - .toLocaleLowerCase() - .split('.') - .slice(0, -1) - .join('.') - .includes(inputValue.toLocaleLowerCase()) - } + const titleMatch = + 'title' in item + ? item.title + .toLocaleLowerCase() + .includes(inputValue.toLocaleLowerCase()) + : item.fileName + .toLocaleLowerCase() + .split('.') + .slice(0, -1) + .join('.') + .includes(inputValue.toLocaleLowerCase()) const categoryId = typeof item.category !== 'string' ? item.category?._id : null @@ -129,11 +154,14 @@ const AddResources = ({ sort: sortOptions, select, selectedRows, + initialSelectedRows, isSelection: true, onAddItems, + onCreateResourceCopy, data: { loading, getItems }, onRowClick, - resource: resourceType + resourceTab, + showCheckboxWithTooltip } return {...props} /> diff --git a/src/containers/cooperation-details/cooperetion-activities-view/CooperationActivitiesView.style.ts b/src/containers/cooperation-details/cooperation-activities-view/CooperationActivitiesView.style.ts similarity index 100% rename from src/containers/cooperation-details/cooperetion-activities-view/CooperationActivitiesView.style.ts rename to src/containers/cooperation-details/cooperation-activities-view/CooperationActivitiesView.style.ts diff --git a/src/containers/cooperation-details/cooperetion-activities-view/CooperationActivitiesView.tsx b/src/containers/cooperation-details/cooperation-activities-view/CooperationActivitiesView.tsx similarity index 82% rename from src/containers/cooperation-details/cooperetion-activities-view/CooperationActivitiesView.tsx rename to src/containers/cooperation-details/cooperation-activities-view/CooperationActivitiesView.tsx index 834dba5c5..8fe935d7b 100644 --- a/src/containers/cooperation-details/cooperetion-activities-view/CooperationActivitiesView.tsx +++ b/src/containers/cooperation-details/cooperation-activities-view/CooperationActivitiesView.tsx @@ -1,41 +1,41 @@ import { FC, Dispatch, SetStateAction } from 'react' + import Box from '@mui/material/Box' -import { IconButton } from '@mui/material' import EditIcon from '@mui/icons-material/Edit' +import { IconButton } from '@mui/material' import CooperationSectionView from '~/components/cooperation-section-view/CooperationSectionView' -import { useAppDispatch, useAppSelector } from '~/hooks/use-redux' +import { styles } from '~/containers/cooperation-details/cooperation-activities-view/CooperationActivitiesView.style' + import { cooperationsSelector, setIsAddedClicked } from '~/redux/features/cooperationsSlice' -import { styles } from '~/containers/cooperation-details/cooperetion-activities-view/CooperationActivitiesView.style' -import { CourseSection, UserRoleEnum } from '~/types' +import { UserRoleEnum } from '~/types' +import { useAppDispatch, useAppSelector } from '~/hooks/use-redux' interface CooperationActivitiesViewProps { - sections: CourseSection[] setEditMode: Dispatch> } const CooperationActivitiesView: FC = ({ setEditMode }) => { - const { sections } = useAppSelector(cooperationsSelector) const dispatch = useAppDispatch() + const { sections } = useAppSelector(cooperationsSelector) + const { userRole } = useAppSelector((state) => state.appMain) + const isTutor = userRole === UserRoleEnum.Tutor const onEdit = () => { - setEditMode() - dispatch(setIsAddedClicked(false)) + setEditMode(true) + dispatch(setIsAddedClicked(false)) // Why is this needed? } - const { userRole } = useAppSelector((state) => state.appMain) - const isTutor = userRole === UserRoleEnum.Tutor - return ( {sections.map((item) => ( - + ))} {isTutor && ( diff --git a/src/containers/cooperation-details/cooperation-activities/CooperationActivities.tsx b/src/containers/cooperation-details/cooperation-activities/CooperationActivities.tsx index 943f088a4..8220f4ea1 100644 --- a/src/containers/cooperation-details/cooperation-activities/CooperationActivities.tsx +++ b/src/containers/cooperation-details/cooperation-activities/CooperationActivities.tsx @@ -1,30 +1,42 @@ +import { Dispatch, FC, SetStateAction, useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' + import Typography from '@mui/material/Typography' import Box from '@mui/material/Box' -import { Link } from 'react-router-dom' -import { Dispatch, FC, SetStateAction } from 'react' import AppSelect from '~/components/app-select/AppSelect' import AppButton from '~/components/app-button/AppButton' import CooperationActivitiesList from '~/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList' -import { useResourceAvailabilityContext } from '~/context/resources-availability-context' +import { cooperationTranslationKeys } from '~/containers/cooperation-details/cooperation-activities/CooperationActivities.constants' +import { styles } from '~/containers/cooperation-details/cooperation-activities/CooperationActivities.styles' + +import openIcon from '~/assets/img/cooperation-details/resource-availability/open-icon.svg' +import closeIcon from '~/assets/img/cooperation-details/resource-availability/closed-icon.svg' + import { cooperationService } from '~/services/cooperation-service' import { authRoutes } from '~/router/constants/authRoutes' -import { useAppDispatch, useAppSelector } from '~/hooks/use-redux' import { openAlert } from '~/redux/features/snackbarSlice' -import { cooperationsSelector } from '~/redux/features/cooperationsSlice' +import { + cooperationsSelector, + setResourcesAvailability +} from '~/redux/features/cooperationsSlice' -import openIcon from '~/assets/img/cooperation-details/resource-availability/open-icon.svg' -import closeIcon from '~/assets/img/cooperation-details/resource-availability/closed-icon.svg' -import { cooperationTranslationKeys } from '~/containers/cooperation-details/cooperation-activities/CooperationActivities.constants' import { snackbarVariants } from '~/constants' import { ResourcesAvailabilityEnum, ButtonVariantEnum, SizeEnum, - ButtonTypeEnum + ButtonTypeEnum, + ErrorResponse, + UpdateCooperationsSections, + UpdateCooperationsParams } from '~/types' -import { styles } from '~/containers/cooperation-details/cooperation-activities/CooperationActivities.styles' + +import useAxios from '~/hooks/use-axios' +import { useAppDispatch, useAppSelector } from '~/hooks/use-redux' +import { getErrorKey } from '~/utils/get-error-key' +import { getErrorMessage } from '~/utils/error-with-message' interface CooperationActivitiesProps { cooperationId?: string @@ -37,15 +49,23 @@ const CooperationActivities: FC = ({ }) => { const { t } = useTranslation() const dispatch = useAppDispatch() - const { sections } = useAppSelector(cooperationsSelector) - const { resourceAvailability, setResourceAvailability } = - useResourceAvailabilityContext() + const { sections, resourcesAvailability } = + useAppSelector(cooperationsSelector) - const updateCooperationSection = async () => { - await cooperationService.updateCooperation({ + const handleResourcesAvailabilityChange = ( + status: ResourcesAvailabilityEnum + ) => { + dispatch(setResourcesAvailability(status)) + } + + const onSaveCooperation = () => { + void updateCooperation({ _id: cooperationId, sections }) + } + + const onUpdateResponse = useCallback(() => { dispatch( openAlert({ severity: snackbarVariants.success, @@ -53,7 +73,39 @@ const CooperationActivities: FC = ({ }) ) setEditMode((prev: boolean) => !prev) - } + }, [dispatch, setEditMode]) + + const onResponseError = useCallback( + (error?: ErrorResponse) => { + const errorKey = getErrorKey(error) + dispatch( + openAlert({ + severity: snackbarVariants.error, + message: error + ? { + text: errorKey, + options: { + message: getErrorMessage(error.message) + } + } + : errorKey + }) + ) + }, + [dispatch] + ) + + const updateCooperationService = ( + data: UpdateCooperationsParams | UpdateCooperationsSections + ) => cooperationService.updateCooperation(data) + + const { fetchData: updateCooperation } = useAxios({ + service: updateCooperationService, + fetchOnMount: false, + defaultResponse: null, + onResponse: onUpdateResponse, + onResponseError + }) const cooperationOption = cooperationTranslationKeys.map( ({ title, value }) => ({ @@ -63,32 +115,32 @@ const CooperationActivities: FC = ({ ) const imgSrc = - resourceAvailability === ResourcesAvailabilityEnum.OpenAll + resourcesAvailability === ResourcesAvailabilityEnum.OpenAll ? openIcon : closeIcon return ( - + resource icon {t('cooperationDetailsPage.publish')} - {t(`cooperationDetailsPage.select.${resourceAvailability}`)} + {t(`cooperationDetailsPage.select.${resourcesAvailability}`)} - {t(`cooperationDetailsPage.${resourceAvailability}`)} + {t(`cooperationDetailsPage.${resourcesAvailability}`)} @@ -104,7 +156,7 @@ const CooperationActivities: FC = ({ {t('common.cancel')} void updateCooperationSection()} + onClick={onSaveCooperation} size={SizeEnum.ExtraLarge} type={ButtonTypeEnum.Submit} > diff --git a/src/containers/course-section/CourseSectionContainer.constants.tsx b/src/containers/course-section/CourseSectionContainer.constants.tsx index f37dfdb1f..4a929324d 100644 --- a/src/containers/course-section/CourseSectionContainer.constants.tsx +++ b/src/containers/course-section/CourseSectionContainer.constants.tsx @@ -2,7 +2,7 @@ import ListAltIcon from '@mui/icons-material/ListAlt' import NoteAltOutlinedIcon from '@mui/icons-material/NoteAltOutlined' import AttachFileIcon from '@mui/icons-material/AttachFile' -import { ResourcesTabsEnum as ResourcesTypes } from '~/types' +import { ResourcesTabsEnum, ResourcesTypesEnum as ResourceType } from '~/types' export const menuTypes = { resourcesMenu: 'resources', @@ -11,20 +11,20 @@ export const menuTypes = { export const resourcesData = { lessons: { - resource: ResourcesTypes.Lessons, + resourceTab: ResourcesTabsEnum.Lessons, icon: }, quizzes: { - resource: ResourcesTypes.Quizzes, + resourceTab: ResourcesTabsEnum.Quizzes, icon: }, attachments: { - resource: ResourcesTypes.Attachments, + resourceTab: ResourcesTabsEnum.Attachments, icon: } } -export const resourceNavigationMap: Partial> = { - [ResourcesTypes.Lessons]: 'editLesson', - [ResourcesTypes.Quizzes]: 'editQuiz' +export const resourceNavigationMap: Partial> = { + [ResourceType.Lesson]: 'editLesson', + [ResourceType.Quiz]: 'editQuiz' } diff --git a/src/containers/course-section/CourseSectionContainer.tsx b/src/containers/course-section/CourseSectionContainer.tsx index cb4b93729..a587b67ae 100644 --- a/src/containers/course-section/CourseSectionContainer.tsx +++ b/src/containers/course-section/CourseSectionContainer.tsx @@ -1,5 +1,6 @@ -import { useState, FC, useEffect, useCallback } from 'react' +import { useState, FC, useMemo, useCallback } from 'react' import { useTranslation } from 'react-i18next' + import { MenuItem } from '@mui/material' import Box from '@mui/material/Box' import AddIcon from '@mui/icons-material/Add' @@ -10,9 +11,7 @@ import AppTextField from '~/components/app-text-field/AppTextField' import AppButton from '~/components/app-button/AppButton' import ResourcesList from '~/containers/course-section/resources-list/ResourcesList' import AddResources from '~/containers/add-resources/AddResources' -import { ResourceService } from '~/services/resource-service' -import useMenu from '~/hooks/use-menu' -import { useModalContext } from '~/context/modal-context' +import EditAttachmentModal from '~/containers/my-resources/edit-attachment-modal/EditAttachmentModal' import { menuTypes, resourceNavigationMap, @@ -30,6 +29,8 @@ import { columns as quizColumns, removeColumnRules as removeQuizColumnRules } from '~/containers/add-resources/AddQuizzes.constants' +import { styles } from '~/containers/course-section/CourseSectionContainer.styles' + import { TextFieldVariantEnum, SizeEnum, @@ -38,168 +39,108 @@ import { Lesson, Quiz, Attachment, - ResourcesTabsEnum as ResourcesTypes, + ResourcesTypesEnum as ResourceType, CourseResource, CourseSectionHandlers, - UpdateAttachmentParams + UpdateAttachmentParams, + CourseResourceEventType, + CourseSectionEventType, + ResourceAvailability } from '~/types' -import { styles } from '~/containers/course-section/CourseSectionContainer.styles' -import { createUrlPath } from '~/utils/helper-functions' import { authRoutes } from '~/router/constants/authRoutes' -import { useAppDispatch } from '~/hooks/use-redux' -import { setIsNewActivity } from '~/redux/features/cooperationsSlice' -import EditAttachmentModal from '~/containers/my-resources/edit-attachment-modal/EditAttachmentModal' +import { ResourceService } from '~/services/resource-service' +import { createUrlPath } from '~/utils/helper-functions' +import { useModalContext } from '~/context/modal-context' import useAxios from '~/hooks/use-axios' +import useMenu from '~/hooks/use-menu' interface SectionProps extends CourseSectionHandlers { sectionData: CourseSection - sections: CourseSection[] + isCooperation?: boolean } type OpenModalFunction = () => void const CourseSectionContainer: FC = ({ sectionData, - sections, - setSectionsItems, handleSectionInputChange, - handleSectionNonInputChange, - handleSectionResourcesOrder + resourceEventHandler, + sectionEventHandler, + isCooperation = false }) => { const { t } = useTranslation() - const dispatch = useAppDispatch() const { openMenu, renderMenu, closeMenu } = useMenu() const { openModal, closeModal } = useModalContext() - const [descriptionInput, setDescriptionInput] = useState( - sectionData.description - ) const [activeMenu, setActiveMenu] = useState('') const [isVisible, setIsVisible] = useState(true) - const [resources, setResources] = useState([]) - const activities: CourseResource[] = sectionData.activities?.map((item) => { - return { ...item.resource, resourceType: item.resourceType } - }) - const getAllResourcesItems = useCallback((): CourseResource[] => { - return activities?.length - ? [...activities] - : [ - ...sectionData.lessons, - ...sectionData.quizzes, - ...sectionData.attachments - ] - }, [ - sectionData.lessons, - sectionData.quizzes, - sectionData.attachments, - activities - ]) + const allResources = useMemo( + () => sectionData.resources.map((item) => item.resource), + [sectionData.resources] + ) - const updateResources = useCallback( - ( - prevResources: CourseResource[], - allResourcesItems: CourseResource[], - displayOrder: string[] - ) => { - return prevResources - .filter((prevResource) => - allResourcesItems.some( - (currentResource) => currentResource._id === prevResource._id - ) - ) - .map((prevResource) => ({ - ...prevResource, - order: displayOrder.indexOf(prevResource._id) - })) - .sort((a, b) => a.order - b.order) - }, - [] + const allNonDuplicateResources = useMemo( + () => allResources.filter((resource) => !resource.isDuplicate), + [allResources] ) - const addNewResources = useCallback( - ( - updatedResourcesItems: CourseResource[], - allResourcesItems: CourseResource[], - displayOrder: string[] - ): CourseResource[] => { - return [ - ...updatedResourcesItems, - ...allResourcesItems - .filter( - (currentResource) => - !updatedResourcesItems.some( - (prevResource) => prevResource._id === currentResource._id - ) - ) - .map((newResource) => ({ - ...newResource, - order: displayOrder.indexOf(newResource._id) - })) - ] - }, - [] + const lessons = useMemo( + () => + allNonDuplicateResources.filter( + (resource) => resource.resourceType === ResourceType.Lesson + ) as Lesson[], + [allNonDuplicateResources] ) - useEffect(() => { - setResources((prevResources) => { - const allResourcesItems = getAllResourcesItems() - const displayOrder = sectionData.order || [] - const updatedResourcesItems = updateResources( - prevResources, - allResourcesItems, - displayOrder - ) + const quizzes = useMemo( + () => + allNonDuplicateResources.filter( + (resource) => resource.resourceType === ResourceType.Quiz + ) as Quiz[], + [allNonDuplicateResources] + ) - return addNewResources( - updatedResourcesItems, - allResourcesItems, - displayOrder - ) - }) - }, [getAllResourcesItems, updateResources, addNewResources, sectionData]) + const attachments = useMemo( + () => + allNonDuplicateResources.filter( + (resource) => resource.resourceType === ResourceType.Attachment + ) as Attachment[], + [allNonDuplicateResources] + ) - useEffect(() => { - if (handleSectionResourcesOrder && sectionData.order) { - handleSectionResourcesOrder(sectionData.id, resources) - } - }, [ - resources, - sectionData.id, - handleSectionResourcesOrder, - sectionData.order - ]) + const handleResourcesSort = useCallback( + (resources: CourseResource[]) => { + resourceEventHandler?.({ + type: CourseResourceEventType.ResourcesOrderChange, + sectionId: sectionData.id, + resources + }) + }, + [sectionData, resourceEventHandler] + ) + + const handleResourceAvailabilityChange = useCallback( + (resource: CourseResource, availability: ResourceAvailability) => { + resourceEventHandler?.({ + type: CourseResourceEventType.ResourceUpdated, + sectionId: sectionData.id, + resourceId: resource.id, + resource: { + availability + } + }) + }, + [sectionData, resourceEventHandler] + ) const deleteResource = (resource: CourseResource) => { - if (resource.resourceType === ResourcesTypes.Lessons) { - const newLessons = sectionData.lessons.filter( - (item) => item._id !== resource._id - ) - handleSectionNonInputChange( - sectionData.id, - resource.resourceType, - newLessons - ) - } else if (resource.resourceType === ResourcesTypes.Quizzes) { - const newQuizzes = sectionData.quizzes.filter( - (item) => item._id !== resource._id - ) - handleSectionNonInputChange( - sectionData.id, - resource.resourceType, - newQuizzes - ) - } else if (resource.resourceType === ResourcesTypes.Attachments) { - const newAttachments = sectionData.attachments.filter( - (item) => item._id !== resource._id - ) - handleSectionNonInputChange( - sectionData.id, - resource.resourceType, - newAttachments - ) - } + resourceEventHandler?.({ + type: CourseResourceEventType.ResourceRemoved, + sectionId: sectionData.id, + resourceId: resource.id + }) } const handleEditAttachment = (params?: UpdateAttachmentParams) => @@ -209,14 +150,12 @@ const CourseSectionContainer: FC = ({ service: handleEditAttachment, fetchOnMount: false, onResponse: (attachment: Attachment) => { - setResources((prev) => - prev.map((resource) => { - if (resource._id === attachment._id) { - return { ...resource, ...attachment } - } - return resource - }) - ) + resourceEventHandler?.({ + type: CourseResourceEventType.ResourceUpdated, + sectionId: sectionData.id, + resourceId: attachment._id, + resource: attachment + }) } }) @@ -225,7 +164,7 @@ const CourseSectionContainer: FC = ({ if (!resourceType) return - if (resourceType === ResourcesTypes.Attachments) { + if (resourceType === ResourceType.Attachment) { openModal({ component: ( = ({ } const onDeleteSection = () => { - dispatch(setIsNewActivity(false)) - setSectionsItems(sections.filter((item) => item.id !== sectionData.id)) + sectionEventHandler?.({ + type: CourseSectionEventType.SectionRemoved, + sectionId: sectionData.id + }) } - const handleAddResources = ( newResources: T[], - type: ResourcesTypes + isDuplicate: boolean ) => { - handleSectionNonInputChange( - sectionData.id, - type as keyof CourseSection, - newResources - ) + resourceEventHandler?.({ + type: CourseResourceEventType.AddSectionResources, + sectionId: sectionData.id, + resources: newResources, + isDuplicate: isDuplicate + }) } const handleOpenAddLessonsModal = () => { @@ -278,13 +219,12 @@ const CourseSectionContainer: FC = ({ component: ( columns={lessonColumns} - onAddResources={(resources) => - handleAddResources(resources, ResourcesTypes.Lessons) - } + onAddResources={handleAddResources} removeColumnRules={removeLessonColumnRules} requestService={ResourceService.getUsersLessons} - resourceType={resourcesData.lessons.resource} - resources={sectionData.lessons} + resourceTab={resourcesData.lessons.resourceTab} + resources={lessons} + showCheckboxWithTooltip /> ) }) @@ -295,13 +235,12 @@ const CourseSectionContainer: FC = ({ component: ( columns={quizColumns} - onAddResources={(resources) => { - handleAddResources(resources, ResourcesTypes.Quizzes) - }} + onAddResources={handleAddResources} removeColumnRules={removeQuizColumnRules} requestService={ResourceService.getQuizzes} - resourceType={resourcesData.quizzes.resource} - resources={sectionData.quizzes} + resourceTab={resourcesData.quizzes.resourceTab} + resources={quizzes} + showCheckboxWithTooltip /> ) }) @@ -312,13 +251,12 @@ const CourseSectionContainer: FC = ({ component: ( columns={attachmentColumns} - onAddResources={(resources) => - handleAddResources(resources, ResourcesTypes.Attachments) - } + onAddResources={handleAddResources} removeColumnRules={removeAttachmentColumnRules} requestService={ResourceService.getAttachments} - resourceType={resourcesData.attachments.resource} - resources={sectionData.attachments} + resourceTab={resourcesData.attachments.resourceTab} + resources={attachments} + showCheckboxWithTooltip /> ) }) @@ -380,7 +318,7 @@ const CourseSectionContainer: FC = ({ fullWidth inputProps={styles.input} label={ - descriptionInput + sectionData.description ? '' : t('course.courseSection.defaultNewDescription') } @@ -391,15 +329,23 @@ const CourseSectionContainer: FC = ({ event.target.value ) } - onChange={(event) => setDescriptionInput(event.target.value)} - value={descriptionInput} + onChange={(event) => + handleSectionInputChange( + sectionData.id, + 'description', + event.target.value + ) + } + value={sectionData.description} variant={TextFieldVariantEnum.Standard} /> } diff --git a/src/containers/course-section/resource-item/ResourceItem.styles.ts b/src/containers/course-section/resource-item/ResourceItem.styles.ts index a8fd5ac74..e8e4feb78 100644 --- a/src/containers/course-section/resource-item/ResourceItem.styles.ts +++ b/src/containers/course-section/resource-item/ResourceItem.styles.ts @@ -24,6 +24,7 @@ export const styles = { }, datePicker: { display: 'flex', + alignItems: 'center', ...SlideLeftLongAnimation, '& .MuiTextField-root': { fontSize: '14px', @@ -40,5 +41,9 @@ export const styles = { }, editBtn: { color: palette.basic.blueGray + }, + linkBtn: { + color: palette.basic.blueGray, + transform: 'rotate(315deg)' } } diff --git a/src/containers/course-section/resource-item/ResourceItem.tsx b/src/containers/course-section/resource-item/ResourceItem.tsx index 2286ad811..8523396e0 100644 --- a/src/containers/course-section/resource-item/ResourceItem.tsx +++ b/src/containers/course-section/resource-item/ResourceItem.tsx @@ -1,27 +1,18 @@ -import { FC, useCallback, useEffect } from 'react' +import { FC, useCallback } from 'react' import { useTranslation } from 'react-i18next' import Box from '@mui/material/Box' +import TextField from '@mui/material/TextField' import IconButton from '@mui/material/IconButton' import CloseIcon from '@mui/icons-material/Close' +import EditIcon from '@mui/icons-material/Edit' +import LinkRoundedIcon from '@mui/icons-material/LinkRounded' import { DatePicker } from '@mui/x-date-pickers/DatePicker' -import TextField from '@mui/material/TextField' import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns' import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider' -import EditIcon from '@mui/icons-material/Edit' import AppSelect from '~/components/app-select/AppSelect' import IconExtensionWithTitle from '~/components/icon-extension-with-title/IconExtensionWithTitle' -import { useResourceAvailabilityContext } from '~/context/resources-availability-context' - -import { - CourseResource, - ResourceAvailabilityStatusEnum, - ResourcesAvailabilityEnum, - ResourcesTabsEnum as ResourcesTypes, - SetResourseAvailability, - SizeEnum -} from '~/types' import { availabilityIcons, @@ -30,84 +21,92 @@ import { import { resourcesData } from '~/containers/course-section/CourseSectionContainer.constants' import { styles } from '~/containers/course-section/resource-item/ResourceItem.styles' +import { + CourseResource, + ResourceAvailability, + ResourceAvailabilityStatusEnum, + ResourcesTypesEnum as ResourceType, + SizeEnum +} from '~/types' + interface ResourceItemProps { resource: CourseResource - resourceType?: ResourcesTypes + resourceType?: ResourceType deleteResource?: (resource: CourseResource) => void - setResourceAvailability?: SetResourseAvailability editResource?: (resource: CourseResource) => void + updateAvailability?: ( + resource: CourseResource, + availability: ResourceAvailability + ) => void isView?: boolean + isCooperation?: boolean } const ResourceItem: FC = ({ resource, resourceType, deleteResource, - setResourceAvailability, editResource, - isView = false + updateAvailability, + isView = false, + isCooperation = false }) => { - const handleDeleteResource = () => { + const { t } = useTranslation() + + const handleDeleteResource = useCallback(() => { deleteResource?.(resource) - } + }, [deleteResource, resource]) - const handleEditResource = () => { + const handleEditResource = useCallback(() => { editResource?.(resource) - } + }, [editResource, resource]) - const { t } = useTranslation() - - const { resourceAvailability: allResourcesAvailability, isCooperation } = - useResourceAvailabilityContext() + const handleLinkResource = useCallback(() => { + editResource?.(resource) + }, [editResource, resource]) - const renderResourceIcon = () => { - const { Lessons, Quizzes } = ResourcesTypes + const renderResourceIcon = useCallback(() => { + const { Lesson, Quiz } = ResourceType const type = resourceType || resource.resourceType switch (type) { - case Lessons: + case Lesson: return resourcesData.lessons.icon - case Quizzes: + case Quiz: return resourcesData.quizzes.icon default: return null } - } + }, [resourceType, resource.resourceType]) const resourceAvailability = resource.availability - const resourceAvailabilityStatus = resourceAvailability?.status ?? ResourceAvailabilityStatusEnum.Open const shouldShowDatePicker = resourceAvailabilityStatus === ResourceAvailabilityStatusEnum.OpenFrom - const setOpenFromDate = (value: Date | null) => { - setResourceAvailability?.(resource._id, { - status: resourceAvailabilityStatus, - date: value - }) - } + const setOpenFromDate = useCallback( + (date: Date | null) => { + updateAvailability?.(resource, { + status: resourceAvailabilityStatus, + date: date?.toISOString() ?? null + }) + }, + [resource, resourceAvailabilityStatus, updateAvailability] + ) const setAvailabilityStatus = useCallback( (status: ResourceAvailabilityStatusEnum) => { - setResourceAvailability?.(resource._id, { - status: status, + updateAvailability?.(resource, { + status, date: null }) }, - [resource._id, setResourceAvailability] + [resource, updateAvailability] ) - useEffect(() => { - if (allResourcesAvailability === ResourcesAvailabilityEnum.OpenManually) { - setAvailabilityStatus(ResourceAvailabilityStatusEnum.Closed) - } else { - setAvailabilityStatus(ResourceAvailabilityStatusEnum.Open) - } - }, [allResourcesAvailability, setAvailabilityStatus]) - const availabilityIcon = ( = ({ {availabilityIcon} } - value={resourceAvailability.date ?? null} + value={resourceAvailability?.date ?? null} /> )} setAvailabilityStatus(value)} + setValue={setAvailabilityStatus} sx={styles.availabilitySelect} value={resourceAvailabilityStatus} /> @@ -152,9 +152,15 @@ const ResourceItem: FC = ({ ) : ( {isCooperation && availabilitySelection} - - - + {resource.isDuplicate ? ( + + + + ) : ( + + + + )} diff --git a/src/containers/course-section/resources-list/ResourcesList.tsx b/src/containers/course-section/resources-list/ResourcesList.tsx index 6cae481bf..9343864a1 100644 --- a/src/containers/course-section/resources-list/ResourcesList.tsx +++ b/src/containers/course-section/resources-list/ResourcesList.tsx @@ -1,58 +1,52 @@ -import { FC, Dispatch, SetStateAction, useCallback } from 'react' +import { FC } from 'react' import { DndContext, DragOverlay } from '@dnd-kit/core' import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' import Box from '@mui/material/Box' -import SortableWrapper from '~/containers/sortable-wrapper/SortableWrapper' import DragHandle from '~/components/drag-handle/DragHandle' +import SortableWrapper from '~/containers/sortable-wrapper/SortableWrapper' import ResourceItem from '~/containers/course-section/resource-item/ResourceItem' import { styles } from '~/containers/course-section/resources-list/ResourcesList.styles' +import { CourseResource, ResourceAvailability } from '~/types' + import useDroppable from '~/hooks/use-droppable' import useDndSensor from '~/hooks/use-dnd-sensor' -import { CourseResource, SetResourseAvailability } from '~/types' interface ResourcesListProps { items: CourseResource[] - setResources: Dispatch> + sortResources: (resources: CourseResource[]) => void deleteResource: (resource: CourseResource) => void editResource: (resource: CourseResource) => void + updateAvailability?: ( + resource: CourseResource, + availability: ResourceAvailability + ) => void + isCooperation?: boolean } const ResourcesList: FC = ({ items, - setResources, + sortResources, deleteResource, - editResource + editResource, + updateAvailability, + isCooperation = false }) => { const { enabled } = useDroppable() - const setResourceAvailability: SetResourseAvailability = useCallback( - (id, availability) => { - setResources((prevResources) => { - const resources = [...prevResources] - const resource = resources.find((item) => item._id === id) - if (resource) { - resource.availability = availability - } - return resources - }) - }, - [setResources] - ) - const { activeItem, handleDragCancel, handleDragEnd, handleDragStart, sensors - } = useDndSensor({ items, setItems: setResources, idProp: '_id' }) + } = useDndSensor({ items, setItems: sortResources, idProp: 'id' }) const renderItem = (item: CourseResource, isDragOver = false) => ( @@ -60,8 +54,9 @@ const ResourcesList: FC = ({ ) @@ -71,7 +66,7 @@ const ResourcesList: FC = ({ const resourceListContent = enabled && ( <> item._id)} + items={items.map((item) => item.id)} strategy={verticalListSortingStrategy} > {resourceItems} diff --git a/src/containers/course-sections-list/CourseSectionsList.tsx b/src/containers/course-sections-list/CourseSectionsList.tsx index aa086ca06..08d3f6db8 100644 --- a/src/containers/course-sections-list/CourseSectionsList.tsx +++ b/src/containers/course-sections-list/CourseSectionsList.tsx @@ -1,7 +1,8 @@ -import { FC, Fragment, MouseEvent } from 'react' +import { FC, Fragment, MouseEvent, useState } from 'react' +import { useTranslation } from 'react-i18next' import { DndContext, DragOverlay } from '@dnd-kit/core' import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' -import { useTranslation } from 'react-i18next' + import Box from '@mui/material/Box' import Crop75Icon from '@mui/icons-material/Crop75' import Divider from '@mui/material/Divider' @@ -9,7 +10,6 @@ import MenuItem from '@mui/material/MenuItem' import Typography from '@mui/material/Typography' import ViewComfyOutlinedIcon from '@mui/icons-material/ViewComfyOutlined' import { Add } from '@mui/icons-material' -import { v4 as uuidv4 } from 'uuid' import AddCourseTemplateModal from '~/containers/cooperation-details/add-course-modal-modal/AddCourseTemplateModal' import SortableWrapper from '~/containers/sortable-wrapper/SortableWrapper' @@ -17,35 +17,44 @@ import CourseSectionContainer from '~/containers/course-section/CourseSectionCon import DragHandle from '~/components/drag-handle/DragHandle' import { styles } from '~/containers/course-sections-list/CourseSectionsList.styles' +import { + CourseSection, + CourseSectionEventType, + CourseSectionHandlers +} from '~/types' +import { useModalContext } from '~/context/modal-context' +import { setIsAddedClicked } from '~/redux/features/cooperationsSlice' + import useDroppable from '~/hooks/use-droppable' import useMenu from '~/hooks/use-menu' import useDndSensor from '~/hooks/use-dnd-sensor' -import { CourseSection, CourseSectionHandlers } from '~/types' -import { useModalContext } from '~/context/modal-context' -import { useAppDispatch, useAppSelector } from '~/hooks/use-redux' -import { - cooperationsSelector, - setCurrentSectionIndex, - setIsAddedClicked -} from '~/redux/features/cooperationsSlice' +import { useAppDispatch } from '~/hooks/use-redux' interface CourseSectionsListProps extends CourseSectionHandlers { items: CourseSection[] isCooperation?: boolean - addNewSection?: (index?: number) => void } const CourseSectionsList: FC = ({ items, - setSectionsItems, handleSectionInputChange, - handleSectionNonInputChange, - handleSectionResourcesOrder, - isCooperation = false, - addNewSection + resourceEventHandler, + sectionEventHandler, + isCooperation = false }) => { + const dispatch = useAppDispatch() + const { t } = useTranslation() const { enabled } = useDroppable() - const Id = uuidv4() + const { anchorEl, openMenu, closeMenu, renderMenu } = useMenu() + const { openModal, closeModal } = useModalContext() + const [currentSectionIndex, setCurrentSectionIndex] = useState(0) + + const handleSectionsSort = (sections: CourseSection[]) => { + sectionEventHandler?.({ + type: CourseSectionEventType.SectionsOrderChange, + sections + }) + } const { activeItem, @@ -53,32 +62,27 @@ const CourseSectionsList: FC = ({ handleDragEnd, handleDragStart, sensors - } = useDndSensor({ items, setItems: setSectionsItems, idProp: 'id' }) - - const { anchorEl, openMenu, closeMenu, renderMenu } = useMenu() - - const { openModal, closeModal } = useModalContext() - const { currentSectionIndex } = useAppSelector(cooperationsSelector) - const dispatch = useAppDispatch() - const { t } = useTranslation() + } = useDndSensor({ items, setItems: handleSectionsSort, idProp: 'id' }) - const handleActivitiesMenuClick = (event: MouseEvent) => { + const handleActivitiesMenuClick = ( + event: MouseEvent, + index: number + ) => { + setCurrentSectionIndex(index) openMenu(event) - dispatch( - setCurrentSectionIndex( - items.findIndex((item) => item.id === event.currentTarget.id) - ) - ) } const handleMenuItemClick = () => { closeMenu() - addNewSection?.(currentSectionIndex) + sectionEventHandler?.({ + type: CourseSectionEventType.SectionAdded, + index: currentSectionIndex + }) } const openAddCourseTemplateModal = () => { closeMenu() - dispatch(setIsAddedClicked(false)) + dispatch(setIsAddedClicked(false)) // Why is this needed? openModal({ component: }) @@ -99,7 +103,7 @@ const CourseSectionsList: FC = ({ } ] - const addActivityMenuList = addActivityMenuItems.map( + const renderActivityMenuList = addActivityMenuItems.map( ({ id, label, icon, onClick }) => ( {icon} @@ -108,22 +112,22 @@ const CourseSectionsList: FC = ({ ) ) - const clearCoorperationMenu = isCooperation && ( + const renderAddActivityBtnMenu = (label: string, index: number) => ( handleActivitiesMenuClick(e, index)} sx={styles.activityButton} > - {t(`cooperationsPage.button.create`)} + {label} - {renderMenu(addActivityMenuList)} + {renderMenu(renderActivityMenuList)} ) - const sectionsItem = (item: CourseSection, isDragOver = false) => { - const coorperationMenu = isCooperation && ( + const cooperationActivityMenu = (index: number) => { + return ( = ({ : styles.activityButtonContainerDefault } > - - - {t(`cooperationsPage.button.add`)} - - - {renderMenu(addActivityMenuList)} - + {renderAddActivityBtnMenu(t('cooperationsPage.button.add'), index)} ) + } + const renderSectionItem = ( + item: CourseSection, + index: number, + isDragOver = false + ) => { return ( - {coorperationMenu} + {isCooperation && cooperationActivityMenu(index)} @@ -161,27 +160,32 @@ const CourseSectionsList: FC = ({ /> ) } + const sectionItems = items.length === 0 - ? clearCoorperationMenu - : items.map((item) => sectionsItem(item)) + ? renderAddActivityBtnMenu(t('cooperationsPage.button.create'), 0) + : items.map((item, index) => renderSectionItem(item, index)) const courseSectionContent = enabled && ( <> - + item.id)} + strategy={verticalListSortingStrategy} + > {sectionItems} - {activeItem && sectionsItem(activeItem, true)} + + {activeItem && renderSectionItem(activeItem, 0, true)} + ) return ( diff --git a/src/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.constants.ts b/src/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.constants.ts deleted file mode 100644 index 024758db7..000000000 --- a/src/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.constants.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { CourseSection } from '~/types' - -export const initialCooperationSectionData: CourseSection = { - id: '', - title: '', - description: '', - lessons: [], - quizzes: [], - attachments: [], - order: [] -} diff --git a/src/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.tsx b/src/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.tsx index 8ee6e412e..e461916ba 100644 --- a/src/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.tsx +++ b/src/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.tsx @@ -1,39 +1,52 @@ -import Box from '@mui/material/Box' +import { useCallback, useEffect } from 'react' import { v4 as uuidv4 } from 'uuid' -import CourseSectionsList from '~/containers/course-sections-list/CourseSectionsList' +import Box from '@mui/material/Box' + import Loader from '~/components/loader/Loader' +import CourseSectionsList from '~/containers/course-sections-list/CourseSectionsList' -import { useEffect } from 'react' -import { CourseSection, CourseResource, CourseFieldValues } from '~/types' -import { useAppSelector, useAppDispatch } from '~/hooks/use-redux' +import { + CourseSection, + CourseResource, + CourseFieldValues, + ResourceEventHandler, + SectionEventHandler, + CourseSectionEventType, + CourseResourceEventType +} from '~/types' import { cooperationsSelector, + deleteCooperationSection, + deleteResource, setCooperationSections, - updateCooperationSection + addNewCooperationSection, + setIsNewActivity, + addSectionResources, + updateCooperationSection, + updateResource, + updateResourcesOrder } from '~/redux/features/cooperationsSlice' -import { initialCooperationSectionData } from '~/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.constants' + +import { useAppSelector, useAppDispatch } from '~/hooks/use-redux' const CooperationActivitiesList = () => { - const { - selectedCourse, - isAddedClicked, - currentSectionIndex, - isNewActivity, - sections - } = useAppSelector(cooperationsSelector) const dispatch = useAppDispatch() - const Id = uuidv4() + const { selectedCourse, isAddedClicked, sections } = + useAppSelector(cooperationsSelector) + // This logic looks very complicated and seems that it doesn't work + // Why do we need to store some flags for user actions? + // isAddedClicked works even when we don't click, that adds unnecessary sections useEffect(() => { - if (!sections?.length && !isAddedClicked && isNewActivity) { - addNewSection() - } + // if (!sections?.length && !isAddedClicked && isNewActivity) { // commented because this if causes adding two sections + // dispatch(addNewCooperationSection({ index: 0 })) // should check and rewrite this logic + // } if (selectedCourse && !sections.length && isAddedClicked) { const allSections = selectedCourse.sections.map((section) => ({ ...section, - id: Id + id: uuidv4() })) setSectionsData(allSections) } @@ -42,7 +55,7 @@ const CooperationActivitiesList = () => { const addNewSectionsCourse = (index: number | undefined = undefined) => { const newSectionData = selectedCourse.sections.map((section) => ({ ...section, - id: Id + id: uuidv4() })) let newSections if (index !== undefined) { @@ -56,18 +69,18 @@ const CooperationActivitiesList = () => { } setSectionsData(newSections) } - addNewSectionsCourse(currentSectionIndex) + addNewSectionsCourse(0) // this is a mock and will always insert at the 0 position, but it will change in issue #2064 } // eslint-disable-next-line react-hooks/exhaustive-deps }, [isAddedClicked]) - if (sections === undefined) { - return - } + const setSectionsData = useCallback( + (sections: CourseSection[]) => { + dispatch(setCooperationSections(sections)) + }, + [dispatch] + ) - const setSectionsData = (value: CourseSection[]) => { - dispatch(setCooperationSections(value)) - } const handleSectionChange = ( id: string, field: keyof CourseSection, @@ -82,30 +95,85 @@ const CooperationActivitiesList = () => { ) } - const addNewSection = (index: number | undefined = undefined) => { - const newSectionData = { ...initialCooperationSectionData } - newSectionData.id = Date.now().toString() - const newSections = - index !== null - ? [ - ...sections.slice(0, index), - newSectionData, - ...sections.slice(index) - ] - : [...sections, newSectionData] + const deleteSection = useCallback( + (sectionId: string) => { + dispatch(setIsNewActivity(false)) // Why do we need this flag here? (moved from the child component) + dispatch(deleteCooperationSection(sectionId)) + }, + [dispatch] + ) - setSectionsData(newSections) + const sectionEventHandler = useCallback( + (event) => { + switch (event.type) { + case CourseSectionEventType.SectionAdded: + dispatch(addNewCooperationSection({ index: event.index })) + break + case CourseSectionEventType.SectionRemoved: + deleteSection(event.sectionId) + break + case CourseSectionEventType.SectionsOrderChange: + setSectionsData(event.sections) + break + } + }, + [dispatch, deleteSection, setSectionsData] + ) + + const resourceEventHandler = useCallback( + (event) => { + switch (event.type) { + case CourseResourceEventType.ResourceUpdated: + dispatch( + updateResource({ + sectionId: event.sectionId, + resourceId: event.resourceId, + resource: event.resource + }) + ) + break + case CourseResourceEventType.ResourcesOrderChange: + dispatch( + updateResourcesOrder({ + sectionId: event.sectionId, + resources: event.resources + }) + ) + break + case CourseResourceEventType.AddSectionResources: + dispatch( + addSectionResources({ + sectionId: event.sectionId, + resources: event.resources, + isDuplicate: event.isDuplicate + }) + ) + break + case CourseResourceEventType.ResourceRemoved: + dispatch( + deleteResource({ + sectionId: event.sectionId, + resourceId: event.resourceId + }) + ) + break + } + }, + [dispatch] + ) + + if (sections === undefined) { + return } return ( diff --git a/src/containers/my-cooperations/cooperation-details/CooperationDetails.constants.tsx b/src/containers/my-cooperations/cooperation-details/CooperationDetails.constants.tsx index c1b70a5e4..1d4e47c1d 100644 --- a/src/containers/my-cooperations/cooperation-details/CooperationDetails.constants.tsx +++ b/src/containers/my-cooperations/cooperation-details/CooperationDetails.constants.tsx @@ -71,6 +71,7 @@ export const defaultResponse: Cooperation = { proficiencyLevel: ProficiencyLevelEnum.Beginner, status: StatusEnum.Active, needAction: UserRoleEnum.Tutor, + sections: [], createdAt: '', updatedAt: '', _id: '' diff --git a/src/containers/my-cooperations/cooperation-details/CooperationDetails.tsx b/src/containers/my-cooperations/cooperation-details/CooperationDetails.tsx index 6995034d9..97c233339 100644 --- a/src/containers/my-cooperations/cooperation-details/CooperationDetails.tsx +++ b/src/containers/my-cooperations/cooperation-details/CooperationDetails.tsx @@ -21,9 +21,7 @@ import { useAppDispatch, useAppSelector } from '~/hooks/use-redux' import CooperationActivities from '~/containers/cooperation-details/cooperation-activities/CooperationActivities' import CooperationNotes from '~/containers/my-cooperations/cooperation-notes/CooperationNotes' -import CooperationActivitiesView from '~/containers/cooperation-details/cooperetion-activities-view/CooperationActivitiesView' - -import { errorRoutes } from '~/router/constants/errorRoutes' +import CooperationActivitiesView from '~/containers/cooperation-details/cooperation-activities-view/CooperationActivitiesView' import { tabsData, defaultResponse, @@ -31,8 +29,8 @@ import { } from '~/containers/my-cooperations/cooperation-details/CooperationDetails.constants' import { styles } from '~/containers/my-cooperations/cooperation-details/CooperationDetails.styles' +import { errorRoutes } from '~/router/constants/errorRoutes' import { cooperationService } from '~/services/cooperation-service' -import { ResourcesAvailabilityProvider } from '~/context/resources-availability-context' import { CooperationTabsEnum, @@ -48,17 +46,17 @@ import { } from '~/redux/features/cooperationsSlice' const CooperationDetails = () => { + const dispatch = useAppDispatch() + const navigate = useNavigate() const { t } = useTranslation() const { id } = useParams() - const { isActivityCreated } = useAppSelector(cooperationsSelector) - const navigate = useNavigate() const { isDesktop } = useBreakpoints() + const { isActivityCreated } = useAppSelector(cooperationsSelector) // Why is this needed? const [activeTab, setActiveTab] = useState( CooperationTabsEnum.Activities ) - const [isNotesOpen, setIsNotesOpen] = useState(false) - const [editMode, setEditMode] = useState(false) - const dispatch = useAppDispatch() + const [isNotesOpen, setIsNotesOpen] = useState(false) + const [editMode, setEditMode] = useState(false) const responseError = useCallback( () => navigate(errorRoutes.notFound.path), @@ -77,7 +75,7 @@ const CooperationDetails = () => { useEffect(() => { dispatch(setCooperationSections(response.sections)) - response.sections && response.sections.length && setEditMode(true) + setEditMode(Boolean(response?.sections?.length)) }, [response.sections, dispatch]) const handleEditMode = useCallback(() => { @@ -157,11 +155,7 @@ const CooperationDetails = () => { - - - {pageContent()} - - + {pageContent()} {!isDesktop && isNotesOpen && ( [] = [ { label: 'cooperationsPage.tableHeaders.name', field: 'name', @@ -50,7 +50,7 @@ export const columns = [ { label: 'cooperationsPage.tableHeaders.price', field: 'price', - calculatedCellValue: (item: Offer, { t }: AdditionalPropsInterface) => + calculatedCellValue: (item: Cooperation, { t }: AdditionalPropsInterface) => `${item.price} ${t('common.uah')}` }, { diff --git a/src/containers/my-cooperations/cooperations-container/CooperationContainer.tsx b/src/containers/my-cooperations/cooperations-container/CooperationContainer.tsx index 4cfa821f0..c2427cf54 100644 --- a/src/containers/my-cooperations/cooperations-container/CooperationContainer.tsx +++ b/src/containers/my-cooperations/cooperations-container/CooperationContainer.tsx @@ -7,7 +7,7 @@ import CooperationCard from '~/containers/my-cooperations/cooperation-card/Coope import { useModalContext } from '~/context/modal-context' import { SortHook } from '~/hooks/table/use-sort' import useBreakpoints from '~/hooks/use-breakpoints' -import { ajustColumns } from '~/utils/helper-functions' +import { adjustColumns } from '~/utils/helper-functions' import { columns, @@ -34,7 +34,7 @@ const CooperationContainer: FC = ({ const { openModal } = useModalContext() const navigate = useNavigate() - const columnsToShow = ajustColumns( + const columnsToShow = adjustColumns( breakpoints, columns, removeColumnRules diff --git a/src/containers/my-cooperations/empty-cooperation-activities/EmptyCooperationTutorControls.tsx b/src/containers/my-cooperations/empty-cooperation-activities/EmptyCooperationTutorControls.tsx index 452b2a523..4c58e9ce3 100644 --- a/src/containers/my-cooperations/empty-cooperation-activities/EmptyCooperationTutorControls.tsx +++ b/src/containers/my-cooperations/empty-cooperation-activities/EmptyCooperationTutorControls.tsx @@ -47,8 +47,8 @@ const EmptyCooperationTutorControls: FC = () => { const handleFromScratch = () => { closeMenu() - dispatch(setIsActivityCreated(true)) - dispatch(setIsNewActivity(true)) + dispatch(setIsActivityCreated(true)) // should delete it + dispatch(setIsNewActivity(true)) // should delete it } const menuItems = [ diff --git a/src/containers/my-offers/my-offers-container/MyOffersContainer.tsx b/src/containers/my-offers/my-offers-container/MyOffersContainer.tsx index f2e192be2..1c33f054f 100644 --- a/src/containers/my-offers/my-offers-container/MyOffersContainer.tsx +++ b/src/containers/my-offers/my-offers-container/MyOffersContainer.tsx @@ -12,7 +12,7 @@ import EditOffer from '~/containers/offer-page/edit-offer/EditOffer' import { SortHook } from '~/hooks/table/use-sort' import useBreakpoints from '~/hooks/use-breakpoints' import { useDrawer } from '~/hooks/use-drawer' -import { ajustColumns, createUrlPath } from '~/utils/helper-functions' +import { adjustColumns, createUrlPath } from '~/utils/helper-functions' import { styles } from '~/containers/my-cooperations/cooperations-container/CooperationContainer.styles' import { @@ -20,7 +20,13 @@ import { removeColumnRules } from '~/containers/my-offers/my-offers-container/MyOffersContainer.constants' import { authRoutes } from '~/router/constants/authRoutes' -import { ButtonVariantEnum, Offer, SizeEnum } from '~/types' +import { + ButtonVariantEnum, + Offer, + SizeEnum, + TableActionFunc, + TableRowAction +} from '~/types' interface MyOffersContainerProps { items: Offer[] @@ -39,7 +45,7 @@ const MyOffersContainer: FC = ({ const { openDrawer, closeDrawer, isOpen } = useDrawer() const [selectedOffer, setSelectedOffer] = useState(null) - const columnsToShow = ajustColumns( + const columnsToShow = adjustColumns( breakpoints, columns, removeColumnRules @@ -68,13 +74,15 @@ const MyOffersContainer: FC = ({ } ] - const editOffer = (id: string) => handleOpenDrawer(id) + const editOffer: TableActionFunc = (id) => { + handleOpenDrawer(id as string) + } - const viewDetails = (id: string) => { - navigate(createUrlPath(authRoutes.offerDetails.path, id)) + const viewDetails: TableActionFunc = (id) => { + navigate(createUrlPath(authRoutes.offerDetails.path, id as string)) } - const rowActions = [ + const rowActions: TableRowAction[] = [ { label: t('myOffersPage.editButton'), func: editOffer }, { label: t('common.labels.viewDetails'), func: viewDetails } ] diff --git a/src/containers/my-quizzes/QuizzesContainer.tsx b/src/containers/my-quizzes/QuizzesContainer.tsx index 18c7dc348..121d3a311 100644 --- a/src/containers/my-quizzes/QuizzesContainer.tsx +++ b/src/containers/my-quizzes/QuizzesContainer.tsx @@ -29,7 +29,7 @@ import { ResourcesTabsEnum } from '~/types' import { - ajustColumns, + adjustColumns, createUrlPath, getScreenBasedLimit } from '~/utils/helper-functions' @@ -47,7 +47,7 @@ const QuizzesContainer = () => { const { sort } = sortOptions const itemsPerPage = getScreenBasedLimit(breakpoints, itemsLoadLimit) - const columnsToShow = ajustColumns( + const columnsToShow = adjustColumns( breakpoints, columns, removeColumnRules diff --git a/src/containers/my-quizzes/create-or-edit-quiz-container/CreateOrEditQuizContainer.constants.ts b/src/containers/my-quizzes/create-or-edit-quiz-container/CreateOrEditQuizContainer.constants.ts index 4d8185a92..5d78663a7 100644 --- a/src/containers/my-quizzes/create-or-edit-quiz-container/CreateOrEditQuizContainer.constants.ts +++ b/src/containers/my-quizzes/create-or-edit-quiz-container/CreateOrEditQuizContainer.constants.ts @@ -1,4 +1,4 @@ -import { QuizViewEnum } from '~/types' +import { QuizViewEnum, ResourcesTypesEnum as ResourceType } from '~/types' export const defaultResponse = { _id: '', @@ -14,6 +14,7 @@ export const defaultResponse = { correctAnswers: false, shuffle: false }, + resourceType: ResourceType.Quiz, createdAt: '', updatedAt: '' } diff --git a/src/containers/my-quizzes/create-or-edit-quiz-container/CreateOrEditQuizContainer.tsx b/src/containers/my-quizzes/create-or-edit-quiz-container/CreateOrEditQuizContainer.tsx index f7f75e6bf..9e1385d3b 100644 --- a/src/containers/my-quizzes/create-or-edit-quiz-container/CreateOrEditQuizContainer.tsx +++ b/src/containers/my-quizzes/create-or-edit-quiz-container/CreateOrEditQuizContainer.tsx @@ -39,6 +39,7 @@ import { SizeEnum, TextFieldVariantEnum, ResourcesTabsEnum, + ResourcesTypesEnum as ResourceType, UpdateQuizParams, CategoryNameInterface, PositionEnum @@ -179,7 +180,7 @@ const CreateOrEditQuizContainer = ({ onAddResources={onAddQuestions} removeColumnRules={removeColumnRules} requestService={ResourceService.getQuestions} - resourceType={ResourcesTabsEnum.Questions} + resourceTab={ResourcesTabsEnum.Questions} resources={questions} /> ) @@ -210,7 +211,13 @@ const CreateOrEditQuizContainer = ({ items: questions, category }) - : void addNewQuiz({ title, description, items: questions, category }) + : void addNewQuiz({ + title, + description, + items: questions, + category, + resourceType: ResourceType.Quiz + }) const CreateQuestionButton = ( extends Omit, 'data'> { @@ -24,17 +31,23 @@ interface AddResourceModalProps getItems: (title: string, selectedItems: string[]) => T[] } selectedRows: T[] + initialSelectedRows: T[] onAddItems: () => void + onCreateResourceCopy?: (value: boolean) => void uploadItem?: (data: FormData) => Promise - resource: string + resourceTab: ResourcesTabsEnum + showCheckboxWithTooltip?: boolean } const AddResourceModal = ({ data, selectedRows, + initialSelectedRows, onAddItems, + onCreateResourceCopy, uploadItem, - resource, + resourceTab, + showCheckboxWithTooltip = false, ...props }: AddResourceModalProps) => { const { t } = useTranslation() @@ -44,20 +57,24 @@ const AddResourceModal = ({ const formData = new FormData() const { loading, getItems } = data - const items = getItems(inputValue, selectedItems) - const handleInputChange = (e: ChangeEvent) => { + const items = useMemo( + () => getItems(inputValue, selectedItems), + [inputValue, selectedItems, getItems] + ) + + const handleInputChange = useCallback((e: ChangeEvent) => { setInputValue(e.target.value) - } + }, []) - const handleInputReset = () => { + const handleInputReset = useCallback(() => { setInputValue('') - } + }, []) return ( - {t(`myResourcesPage.${resource}.add`)} + {t(`myResourcesPage.${resourceTab}.add`)} @@ -81,7 +98,9 @@ const AddResourceModal = ({ ({ {...props} /> - - - {t('common.cancel')} - - - {t('common.add')} - + + {showCheckboxWithTooltip && ( + + )} + + + {t('common.cancel')} + + + {t('common.add')} + + {uploadItem && ( diff --git a/src/containers/my-resources/attachments-container/AttachmentsContainer.tsx b/src/containers/my-resources/attachments-container/AttachmentsContainer.tsx index 131e78f6b..89544c9c8 100644 --- a/src/containers/my-resources/attachments-container/AttachmentsContainer.tsx +++ b/src/containers/my-resources/attachments-container/AttachmentsContainer.tsx @@ -31,7 +31,7 @@ import { ResourcesTabsEnum, ButtonVariantEnum } from '~/types' -import { ajustColumns, getScreenBasedLimit } from '~/utils/helper-functions' +import { adjustColumns, getScreenBasedLimit } from '~/utils/helper-functions' import { styles } from '~/containers/my-resources/attachments-container/AttachmentsContainer.styles' import { useAppDispatch } from '~/hooks/use-redux' import { openAlert } from '~/redux/features/snackbarSlice' @@ -162,7 +162,7 @@ const AttachmentsContainer = () => { }) } - const columnsToShow = ajustColumns( + const columnsToShow = adjustColumns( breakpoints, columns(onAddCategory), removeColumnRules diff --git a/src/containers/my-resources/categories-container/CategoriesContainer.tsx b/src/containers/my-resources/categories-container/CategoriesContainer.tsx index 1608df0ba..ca398f515 100644 --- a/src/containers/my-resources/categories-container/CategoriesContainer.tsx +++ b/src/containers/my-resources/categories-container/CategoriesContainer.tsx @@ -35,7 +35,7 @@ import { CreateCategoriesParams, CategoryNameInterface } from '~/types' -import { ajustColumns, getScreenBasedLimit } from '~/utils/helper-functions' +import { adjustColumns, getScreenBasedLimit } from '~/utils/helper-functions' import { styles } from '~/containers/my-resources/categories-container/CategoriesContainer.style' import { useAppDispatch } from '~/hooks/use-redux' @@ -169,7 +169,7 @@ const CategoriesContainer = () => { const onEdit = (id: string) => setSelectedItemId(id) const onCancel = () => setSelectedItemId('') - const columnsToShow = ajustColumns( + const columnsToShow = adjustColumns( breakpoints, columns( selectedItemId, diff --git a/src/containers/my-resources/lessons-container/LessonsContainer.tsx b/src/containers/my-resources/lessons-container/LessonsContainer.tsx index 81d9c9d02..51b6e4a01 100644 --- a/src/containers/my-resources/lessons-container/LessonsContainer.tsx +++ b/src/containers/my-resources/lessons-container/LessonsContainer.tsx @@ -27,7 +27,7 @@ import { ResourcesTabsEnum } from '~/types' import { - ajustColumns, + adjustColumns, createUrlPath, getScreenBasedLimit } from '~/utils/helper-functions' @@ -46,7 +46,7 @@ const LessonsContainer = () => { const { sort } = sortOptions const itemsPerPage = getScreenBasedLimit(breakpoints, itemsLoadLimit) - const columnsToShow = ajustColumns( + const columnsToShow = adjustColumns( breakpoints, columns, removeColumnRules diff --git a/src/containers/my-resources/questions-container/QuestionsContainer.tsx b/src/containers/my-resources/questions-container/QuestionsContainer.tsx index ff80f6284..f56a8eec0 100644 --- a/src/containers/my-resources/questions-container/QuestionsContainer.tsx +++ b/src/containers/my-resources/questions-container/QuestionsContainer.tsx @@ -28,7 +28,7 @@ import { Question } from '~/types' import { - ajustColumns, + adjustColumns, createUrlPath, getScreenBasedLimit } from '~/utils/helper-functions' @@ -46,7 +46,7 @@ const QuestionsContainer = () => { const { sort } = sortOptions const itemsPerPage = getScreenBasedLimit(breakpoints, itemsLoadLimit) - const columnsToShow = ajustColumns( + const columnsToShow = adjustColumns( breakpoints, columns, removeColumnRules diff --git a/src/context/resources-availability-context.tsx b/src/context/resources-availability-context.tsx deleted file mode 100644 index a9b589c43..000000000 --- a/src/context/resources-availability-context.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { - createContext, - Dispatch, - ReactNode, - SetStateAction, - useContext, - useMemo, - useState -} from 'react' -import { ResourcesAvailabilityEnum } from '~/types' - -interface ResourcesAvailabilityProviderProps { - children: ReactNode -} - -interface ResourcesAvailabilityContextOutput { - resourceAvailability: ResourcesAvailabilityEnum - setResourceAvailability: Dispatch> - isCooperation: boolean -} - -const ResourcesAvailabilityContext = - createContext({ - resourceAvailability: ResourcesAvailabilityEnum.OpenAll - } as ResourcesAvailabilityContextOutput) - -const ResourcesAvailabilityProvider = ({ - children -}: ResourcesAvailabilityProviderProps) => { - const [resourceAvailability, setResourceAvailability] = useState( - ResourcesAvailabilityEnum.OpenAll - ) - - const contextValue = useMemo( - () => ({ - resourceAvailability, - setResourceAvailability, - isCooperation: true - }), - [resourceAvailability, setResourceAvailability] - ) - - return ( - - {children} - - ) -} - -const useResourceAvailabilityContext = () => - useContext(ResourcesAvailabilityContext) - -export { ResourcesAvailabilityProvider, useResourceAvailabilityContext } diff --git a/src/hooks/table/use-select.tsx b/src/hooks/table/use-select.tsx index 513213344..e3558ac61 100644 --- a/src/hooks/table/use-select.tsx +++ b/src/hooks/table/use-select.tsx @@ -16,6 +16,7 @@ interface UseSelectOutput { items: Item[] ) => (e: ChangeEvent) => void handleSelectClick: (id: string) => void + setSelected: (ids: string[]) => void } const useSelect = ({ initialSelect = [] }: UseSelectInput): UseSelectOutput => { @@ -54,7 +55,8 @@ const useSelect = ({ initialSelect = [] }: UseSelectInput): UseSelectOutput => { isSelected, clearSelected, createSelectAllHandler, - handleSelectClick + handleSelectClick, + setSelected } } diff --git a/src/pages/create-course/CreateCourse.constants.tsx b/src/pages/create-course/CreateCourse.constants.tsx index fdfbd16f2..e20817b13 100644 --- a/src/pages/create-course/CreateCourse.constants.tsx +++ b/src/pages/create-course/CreateCourse.constants.tsx @@ -1,14 +1,11 @@ import { CourseSection } from '~/types' import { emptyField, textField } from '~/utils/validations/common' -export const sectionInitialData: Omit = { +export const sectionInitialData: CourseSection = { id: '', title: '', description: '', - lessons: [], - quizzes: [], - attachments: [], - order: [] + resources: [] } export const initialValues = { diff --git a/src/pages/create-course/CreateCourse.tsx b/src/pages/create-course/CreateCourse.tsx index eabd1ce77..8cb8af3c6 100644 --- a/src/pages/create-course/CreateCourse.tsx +++ b/src/pages/create-course/CreateCourse.tsx @@ -1,23 +1,33 @@ import { useCallback, useEffect } from 'react' -import { useNavigate, useParams } from 'react-router-dom' import { useTranslation } from 'react-i18next' +import { useNavigate, useParams } from 'react-router-dom' import { AxiosResponse } from 'axios' +import { v4 as uuidv4 } from 'uuid' + import Box from '@mui/material/Box' import AddIcon from '@mui/icons-material/Add' -import { useAppSelector } from '~/hooks/use-redux' -import useForm from '~/hooks/use-form' -import useAxios from '~/hooks/use-axios' -import { userService } from '~/services/user-service' -import { CourseService } from '~/services/course-service' -import { useAppDispatch } from '~/hooks/use-redux' +import Loader from '~/components/loader/Loader' import PageWrapper from '~/components/page-wrapper/PageWrapper' +import AppButton from '~/components/app-button/AppButton' import CourseSectionsList from '~/containers/course-sections-list/CourseSectionsList' import CourseToolbar from '~/containers/my-courses/course-toolbar/CourseToolbar' -import AppButton from '~/components/app-button/AppButton' -import Loader from '~/components/loader/Loader' +import { userService } from '~/services/user-service' +import { CourseService } from '~/services/course-service' import { getErrorMessage } from '~/utils/error-with-message' +import { getErrorKey } from '~/utils/get-error-key' +import { authRoutes } from '~/router/constants/authRoutes' +import { openAlert } from '~/redux/features/snackbarSlice' + +import { styles } from '~/pages/create-course/CreateCourse.styles' +import { + sectionInitialData, + initialValues, + defaultResponse, + validations +} from '~/pages/create-course/CreateCourse.constants' +import { snackbarVariants } from '~/constants' import { ButtonTypeEnum, ButtonVariantEnum, @@ -25,29 +35,27 @@ import { CourseForm, ComponentEnum, CourseSection, + Resource, Course, ErrorResponse, CourseResource, UserResponse, - UserRole + UserRole, + SectionEventHandler, + CourseSectionEventType, + ResourceEventHandler, + CourseResourceEventType } from '~/types' -import { authRoutes } from '~/router/constants/authRoutes' -import { snackbarVariants } from '~/constants' -import { - sectionInitialData, - initialValues, - defaultResponse, - validations -} from '~/pages/create-course/CreateCourse.constants' -import { styles } from '~/pages/create-course/CreateCourse.styles' -import { openAlert } from '~/redux/features/snackbarSlice' -import { getErrorKey } from '~/utils/get-error-key' + +import useForm from '~/hooks/use-form' +import useAxios from '~/hooks/use-axios' +import { useAppSelector, useAppDispatch } from '~/hooks/use-redux' const CreateCourse = () => { const navigate = useNavigate() + const dispatch = useAppDispatch() const { t } = useTranslation() const { id } = useParams() - const dispatch = useAppDispatch() const { userId, userRole } = useAppSelector((state) => state.appMain) const onResponseError = (error?: ErrorResponse) => { @@ -130,50 +138,203 @@ const CreateCourse = () => { submitWithData: true }) - const setSectionsItems = (value: CourseSection[]) => { - handleNonInputValueChange('sections', value) - } - - const handleSectionResourcesOrder = ( - id: string, - resources: CourseResource[] - ) => { - const sectionToEdit = data.sections.find((section) => section.id === id) - if (sectionToEdit) { - const orderedResources = resources.map((resource) => resource._id) - sectionToEdit.order = orderedResources - } - } - - const handleSectionInputChange = ( - id: string, - field: keyof CourseSection, - value: string - ) => { - const sectionToEdit = data.sections.find((section) => section.id === id) - sectionToEdit && Object.defineProperty(sectionToEdit, field, { value }) - } + const setSectionsData = useCallback( + (sections: CourseSection[]) => { + handleNonInputValueChange('sections', sections) + }, + [handleNonInputValueChange] + ) - const handleSectionNonInputChange = ( - id: string, - field: keyof CourseSection, - value: CourseResource[] - ) => { - const sectionToEdit = data.sections.find((section) => section.id === id) - sectionToEdit && Object.defineProperty(sectionToEdit, field, { value }) - setSectionsItems(data.sections) - } + const handleSectionChange = useCallback( + ( + id: string, + field: keyof CourseSection, + value: string | CourseResource[] | Resource[] + ) => { + const newSections = data.sections.map((section) => + section.id === id ? { ...section, [field]: value } : section + ) + setSectionsData(newSections) + }, + [data.sections, setSectionsData] + ) - const createNewSection = () => { + const addNewSection = useCallback(() => { const newSectionData = { ...sectionInitialData } - newSectionData.id = Date.now().toString() - setSectionsItems([...data.sections, newSectionData]) - } + newSectionData.id = uuidv4() + setSectionsData([...data.sections, newSectionData]) + }, [data.sections, setSectionsData]) if (data.sections.length === 0) { - createNewSection() + addNewSection() } + const deleteSection = useCallback( + (sectionId: string) => { + const sections = data.sections.filter( + (section) => section.id !== sectionId + ) + setSectionsData(sections) + }, + [data.sections, setSectionsData] + ) + + const sectionEventHandler = useCallback( + (event) => { + switch (event.type) { + case CourseSectionEventType.SectionAdded: + addNewSection() + break + case CourseSectionEventType.SectionRemoved: + deleteSection(event.sectionId) + break + case CourseSectionEventType.SectionsOrderChange: + setSectionsData(event.sections) + break + } + }, + [addNewSection, deleteSection, setSectionsData] + ) + + const addSectionResources = useCallback( + ({ + sectionId, + resources, + isDuplicate + }: { + sectionId: CourseSection['id'] + resources: CourseResource[] + isDuplicate?: boolean + }) => { + const section = data.sections.find((section) => section.id === sectionId) + if (!section) return + + const newResources = resources + .filter((resource) => { + return !section.resources.some( + (item) => item.resource.id === resource.id && !isDuplicate + ) + }) + .map((resource) => { + const { _id, ...newDuplicateResource } = resource + return { + resource: { + ...newDuplicateResource, + id: uuidv4(), + ...(isDuplicate ? { _id: '', isDuplicate: true } : { _id }) + }, + resourceType: resource.resourceType + } + }) + + const newSectionResources = [...section.resources, ...newResources] + handleSectionChange(sectionId, 'resources', newSectionResources) + }, + [data.sections, handleSectionChange] + ) + + const updateResource = useCallback( + ({ + sectionId, + resourceId, + resource + }: { + sectionId: CourseSection['id'] + resourceId: CourseResource['id'] + resource: Partial + }) => { + const section = data.sections.find((section) => section.id === sectionId) + if (!section) return + + const currentResource = section.resources.find( + (item) => item.resource.id === resourceId + ) + if (!currentResource) return + + const newSectionResources = section.resources.map((item) => + item.resource.id === resourceId + ? { ...currentResource, ...resource } + : item + ) + + handleSectionChange(sectionId, 'resources', newSectionResources) + }, + [data.sections, handleSectionChange] + ) + + const deleteResource = useCallback( + ({ + sectionId, + resourceId + }: { + sectionId: CourseSection['id'] + resourceId: CourseResource['id'] + }) => { + const section = data.sections.find((section) => section.id === sectionId) + if (!section) return + + const newSectionResources = section.resources.filter( + (resource) => resource.resource.id !== resourceId + ) + handleSectionChange(sectionId, 'resources', newSectionResources) + }, + [data, handleSectionChange] + ) + + const updateResourcesOrder = useCallback( + ({ + sectionId, + resources + }: { + sectionId: CourseSection['id'] + resources: CourseResource[] + }) => { + const section = data.sections.find((section) => section.id === sectionId) + if (!section) return + + const newSectionResources = resources.map((resource) => ({ + resource, + resourceType: resource.resourceType + })) + handleSectionChange(sectionId, 'resources', newSectionResources) + }, + [data.sections, handleSectionChange] + ) + + const resourceEventHandler = useCallback( + (event) => { + switch (event.type) { + case CourseResourceEventType.ResourceUpdated: + updateResource({ + sectionId: event.sectionId, + resourceId: event.resourceId, + resource: event.resource + }) + break + case CourseResourceEventType.ResourcesOrderChange: + updateResourcesOrder({ + sectionId: event.sectionId, + resources: event.resources + }) + break + case CourseResourceEventType.AddSectionResources: + addSectionResources({ + sectionId: event.sectionId, + resources: event.resources, + isDuplicate: event.isDuplicate + }) + break + case CourseResourceEventType.ResourceRemoved: + deleteResource({ + sectionId: event.sectionId, + resourceId: event.resourceId + }) + break + } + }, + [updateResource, updateResourcesOrder, addSectionResources, deleteResource] + ) + const getCourse = (id?: string): Promise => { return CourseService.getCourse(id) } @@ -184,6 +345,11 @@ const CreateCourse = () => { item.id = item._id } }) + course.sections.forEach((section) => { + section.resources?.forEach((resource) => { + resource.resource.id ||= uuidv4() + }) + }) for (const key in data) { const validKey = key as keyof CourseForm handleNonInputValueChange(validKey, course[validKey]) @@ -224,15 +390,14 @@ const CreateCourse = () => { user={user} /> diff --git a/src/pages/create-or-edit-lesson/CreateOrEditLesson.constants.tsx b/src/pages/create-or-edit-lesson/CreateOrEditLesson.constants.tsx index 6bbf789fc..573836d36 100644 --- a/src/pages/create-or-edit-lesson/CreateOrEditLesson.constants.tsx +++ b/src/pages/create-or-edit-lesson/CreateOrEditLesson.constants.tsx @@ -1,4 +1,5 @@ import { emptyField } from '~/utils/validations/common' +import { ResourcesTypesEnum as ResourceType } from '~/types' export const validations = { title: (value: string | null) => @@ -24,7 +25,8 @@ export const defaultResponse = { updatedAt: '', _id: '', content: '', - category: null + category: null, + resourceType: ResourceType.Lesson } export const myResourcesPath = '/my-resources' diff --git a/src/pages/create-or-edit-lesson/CreateOrEditLesson.tsx b/src/pages/create-or-edit-lesson/CreateOrEditLesson.tsx index 95640c5e3..9a88a65b3 100644 --- a/src/pages/create-or-edit-lesson/CreateOrEditLesson.tsx +++ b/src/pages/create-or-edit-lesson/CreateOrEditLesson.tsx @@ -105,7 +105,7 @@ const CreateOrEditLesson = () => { onAddResources={handleAddAttachments} removeColumnRules={removeColumnRules} requestService={ResourceService.getAttachments} - resourceType={ResourcesTabsEnum.Attachments} + resourceTab={ResourcesTabsEnum.Attachments} resources={data.attachments} /> ) diff --git a/src/pages/lesson-details/LessonDetails.constants.tsx b/src/pages/lesson-details/LessonDetails.constants.tsx index f0567c72f..6a4ff7a0b 100644 --- a/src/pages/lesson-details/LessonDetails.constants.tsx +++ b/src/pages/lesson-details/LessonDetails.constants.tsx @@ -1,7 +1,7 @@ import { Lesson, ResourceAvailabilityStatusEnum, - ResourcesTabsEnum + ResourcesTypesEnum as ResourceType } from '~/types' export const defaultResponse: Lesson = { @@ -18,5 +18,5 @@ export const defaultResponse: Lesson = { status: ResourceAvailabilityStatusEnum.Open, date: null }, - resourceType: ResourcesTabsEnum.Lessons + resourceType: ResourceType.Lesson } diff --git a/src/redux/features/cooperationsSlice.ts b/src/redux/features/cooperationsSlice.ts index 2c9d43e5f..508db5ba2 100644 --- a/src/redux/features/cooperationsSlice.ts +++ b/src/redux/features/cooperationsSlice.ts @@ -1,24 +1,39 @@ +import { v4 as uuidv4 } from 'uuid' import { PayloadAction, createSlice } from '@reduxjs/toolkit' -import { Course, CourseFieldValues, CourseSection } from '~/types' import { RootState } from '~/redux/store' import { sliceNames } from '~/redux/redux.constants' +import { + Course, + CourseFieldValues, + CourseResource, + CourseSection, + ResourceAvailabilityStatusEnum, + ResourcesAvailabilityEnum +} from '~/types' interface CooperationsState { - selectedCourse: Course | null - isActivityCreated: boolean - isAddedClicked: boolean - isNewActivity: boolean - currentSectionIndex?: number + selectedCourse: Course | null // delete it + isActivityCreated: boolean // delete it + isAddedClicked: boolean // delete it + isNewActivity: boolean // delete it sections: CourseSection[] + resourcesAvailability: ResourcesAvailabilityEnum } const initialState: CooperationsState = { - selectedCourse: null, - isActivityCreated: false, - isAddedClicked: false, - isNewActivity: false, - currentSectionIndex: 0, - sections: [] + selectedCourse: null, // delete it + isActivityCreated: false, // delete it + isAddedClicked: false, // delete it + isNewActivity: false, // delete it + sections: [], + resourcesAvailability: ResourcesAvailabilityEnum.OpenAll +} + +export const initialCooperationSectionData: CourseSection = { + id: '', + title: '', + description: '', + resources: [] } const cooperationsSlice = createSlice({ @@ -26,41 +41,65 @@ const cooperationsSlice = createSlice({ initialState, reducers: { setSelectedCourse( + // delete it state, action: PayloadAction ) { state.selectedCourse = action.payload }, setIsActivityCreated( + // delete it state, action: PayloadAction ) { state.isActivityCreated = action.payload }, setIsAddedClicked( + // delete it state, action: PayloadAction ) { state.isAddedClicked = action.payload }, setIsNewActivity( + // delete it state, action: PayloadAction ) { state.isNewActivity = action.payload }, - setCurrentSectionIndex( + + setCooperationSections( state, - action: PayloadAction + action: PayloadAction ) { - state.currentSectionIndex = action.payload + state.sections = (action.payload ?? []).map((section) => ({ + ...section, + id: section._id ?? uuidv4(), + resources: (section.resources ?? []).map((resource) => ({ + ...resource, + resource: { ...resource.resource, id: uuidv4() } + })) + })) }, - setCooperationSections( + + addNewCooperationSection( state, - action: PayloadAction + action: PayloadAction<{ + index: number | undefined + }> ) { - state.sections = action.payload + const newSectionData = { ...initialCooperationSectionData } + newSectionData.id = uuidv4() + const newSections = [...state.sections] + newSections.splice( + action.payload.index ?? state.sections.length, + 0, + newSectionData + ) + state.sections = newSections }, + updateCooperationSection( state, action: PayloadAction<{ @@ -76,6 +115,134 @@ const cooperationsSlice = createSlice({ if (sectionToEdit) { sectionToEdit[action.payload.field] = action.payload.value } + }, + + deleteCooperationSection( + state, + action: PayloadAction + ) { + state.sections = state.sections.filter( + (section) => section.id !== action.payload + ) + }, + + addSectionResources( + state, + action: PayloadAction<{ + sectionId: CourseSection['id'] + resources: CourseResource[] + isDuplicate?: boolean + }> + ) { + const isDuplicate = action.payload.isDuplicate + const section = state.sections.find( + (section) => section.id === action.payload.sectionId + ) + + if (!section) return + + const newResources = action.payload.resources + .filter((resource) => { + return !section.resources.some( + (item) => item.resource.id === resource.id && !isDuplicate + ) + }) + .map((resource) => { + const { _id, ...newDuplicateResource } = resource + return { + resource: { + ...newDuplicateResource, + id: uuidv4(), + ...(isDuplicate ? { _id: '', isDuplicate: true } : { _id }) + }, + resourceType: resource.resourceType + } + }) + + section.resources = [...section.resources, ...newResources] + }, + + updateResourcesOrder( + state, + action: PayloadAction<{ + sectionId: CourseSection['id'] + resources: CourseResource[] + }> + ) { + const section = state.sections.find( + (section) => section.id === action.payload.sectionId + ) + + if (!section) return + + section.resources = action.payload.resources.map((resource) => ({ + resource, + resourceType: resource.resourceType + })) + }, + + updateResource( + state, + action: PayloadAction<{ + sectionId: CourseSection['id'] + resourceId: CourseResource['id'] + resource: Partial + }> + ) { + const section = state.sections.find( + (section) => section.id === action.payload.sectionId + ) + + if (!section) return + + const resource = section.resources.find( + (item) => item.resource.id === action.payload.resourceId + ) + + if (!resource) return + + resource.resource = { + ...resource.resource, + ...action.payload.resource + } as CourseResource + }, + + deleteResource( + state, + action: PayloadAction<{ + sectionId: CourseSection['id'] + resourceId: CourseResource['id'] + }> + ) { + const section = state.sections.find( + (section) => section.id === action.payload.sectionId + ) + + if (!section) return + + section.resources = section.resources.filter( + (item) => item.resource.id !== action.payload.resourceId + ) + }, + + setResourcesAvailability( + state, + action: PayloadAction + ) { + state.resourcesAvailability = action.payload + const status: ResourceAvailabilityStatusEnum = + action.payload === ResourcesAvailabilityEnum.OpenAll + ? ResourceAvailabilityStatusEnum.Open + : ResourceAvailabilityStatusEnum.Closed + + for (const section of state.sections ?? []) { + section.resources.forEach((item) => { + item.resource.availability = { + status, + date: null + } + }) + } } } }) @@ -87,9 +254,15 @@ export const { setIsActivityCreated, setIsAddedClicked, setIsNewActivity, - setCurrentSectionIndex, setCooperationSections, - updateCooperationSection + addNewCooperationSection, + updateCooperationSection, + deleteCooperationSection, + addSectionResources, + updateResourcesOrder, + updateResource, + deleteResource, + setResourcesAvailability } = actions export const cooperationsSelector = (state: RootState) => state.cooperations diff --git a/src/types/common/interfaces/common.interfaces.ts b/src/types/common/interfaces/common.interfaces.ts index 8552be23e..311e01347 100644 --- a/src/types/common/interfaces/common.interfaces.ts +++ b/src/types/common/interfaces/common.interfaces.ts @@ -99,17 +99,76 @@ export interface AddDocuments { maxFileNameError: string } +export enum CourseResourceEventType { + ResourceUpdated = 'resourceUpdated', + ResourceRemoved = 'resourceRemoved', + ResourcesOrderChange = 'resourcesOrderChange', + AddSectionResources = 'addSectionResources' +} + +export interface ResourceUpdatedEvent { + type: CourseResourceEventType.ResourceUpdated + sectionId: string + resourceId: string + resource: Partial +} + +export interface ResourceRemovedEvent { + type: CourseResourceEventType.ResourceRemoved + sectionId: string + resourceId: string +} + +export interface ResourcesOrderChangeEvent { + type: CourseResourceEventType.ResourcesOrderChange + sectionId: string + resources: CourseResource[] +} + +export interface AddSectionResourcesEvent { + type: CourseResourceEventType.AddSectionResources + sectionId: string + resources: CourseResource[] + isDuplicate?: boolean +} + +export type ResourceEventHandler = ( + event: + | ResourceUpdatedEvent + | ResourceRemovedEvent + | ResourcesOrderChangeEvent + | AddSectionResourcesEvent +) => void + +export enum CourseSectionEventType { + SectionAdded = 'sectionAdded', + SectionRemoved = 'sectionRemoved', + SectionsOrderChange = 'sectionsOrderChange' +} + +export interface SectionAddedEvent { + type: CourseSectionEventType.SectionAdded + index?: number +} + +export interface SectionRemovedEvent { + type: CourseSectionEventType.SectionRemoved + sectionId: string +} + +export interface SectionsOrderChangeEvent { + type: CourseSectionEventType.SectionsOrderChange + sections: CourseSection[] +} + +export type SectionEventHandler = ( + event: SectionAddedEvent | SectionRemovedEvent | SectionsOrderChangeEvent +) => void + export interface CourseSectionHandlers { - setSectionsItems: (value: CourseSection[]) => void handleSectionInputChange: FormInputValueChange - handleSectionNonInputChange: FormInputValueChange< - CourseResource[], - CourseSection - > - handleSectionResourcesOrder?: ( - id: string, - resources: CourseResource[] - ) => void + resourceEventHandler?: ResourceEventHandler + sectionEventHandler?: SectionEventHandler titleText?: string } diff --git a/src/types/course/interfaces/course.interface.ts b/src/types/course/interfaces/course.interface.ts index 3ee2992dc..e52343c67 100644 --- a/src/types/course/interfaces/course.interface.ts +++ b/src/types/course/interfaces/course.interface.ts @@ -5,11 +5,8 @@ import { SubjectNameInterface, ProficiencyLevelEnum, UserResponse, - Quiz, - Attachment, - Lesson, CourseResource, - ResourcesTabsEnum + ResourcesTypesEnum as ResourceType } from '~/types' export interface Course extends CommonEntityFields { @@ -32,9 +29,9 @@ export interface CourseForm sections: CourseSection[] } -export interface Activities { +export interface Resource { resource: CourseResource - resourceType: ResourcesTabsEnum + resourceType: ResourceType } export interface CourseSection { @@ -42,11 +39,7 @@ export interface CourseSection { id: string title: string description: string - lessons: Lesson[] - quizzes: Quiz[] - attachments: Attachment[] - order?: string[] - activities: Activities[] + resources: Resource[] } export interface CourseFilters extends Pick { diff --git a/src/types/course/types/course.types.ts b/src/types/course/types/course.types.ts index e96e16b11..1399dc916 100644 --- a/src/types/course/types/course.types.ts +++ b/src/types/course/types/course.types.ts @@ -3,24 +3,19 @@ import { Quiz, Attachment, ResourceAvailabilityStatusEnum, - Activities + Resource } from '~/types' export interface ResourceAvailability { status: ResourceAvailabilityStatusEnum - date: Date | null + date: string | null } export type CourseResource = Lesson | Quiz | Attachment -export type SetResourseAvailability = ( +export type SetResourceAvailability = ( sectionId: string, availability: ResourceAvailability ) => void -export type CourseFieldValues = string & - Lesson[] & - Quiz[] & - Attachment[] & - string[] & - Activities[] +export type CourseFieldValues = string & string[] & Resource[] diff --git a/src/types/my-resources/enum/myResources.enum.ts b/src/types/my-resources/enum/myResources.enum.ts index c5b8c0e45..26c18e017 100644 --- a/src/types/my-resources/enum/myResources.enum.ts +++ b/src/types/my-resources/enum/myResources.enum.ts @@ -1,15 +1,16 @@ export enum ResourcesTabsEnum { Lessons = 'lessons', Quizzes = 'quizzes', - Questions = 'questions', Attachments = 'attachments', + Questions = 'questions', Categories = 'categories' } -export enum ResourcesEnum { +export enum ResourcesTypesEnum { Lesson = 'lesson', - Quizz = 'quiz', - Attachment = 'attachment' + Quiz = 'quiz', + Attachment = 'attachment', + Question = 'question' } export enum QuestionTypesEnum { diff --git a/src/types/my-resources/interfaces/myResources.interface.ts b/src/types/my-resources/interfaces/myResources.interface.ts index ac6f1f961..1fce1f8c5 100644 --- a/src/types/my-resources/interfaces/myResources.interface.ts +++ b/src/types/my-resources/interfaces/myResources.interface.ts @@ -4,13 +4,15 @@ import { CommonEntityFields, RequestParams, ResourceAvailability, - ResourcesTabsEnum as ResourcesTypes + ResourcesTypesEnum as ResourceType } from '~/types' export interface ResourceBase { + id: string description: string - resourceType: ResourcesTypes - availability: ResourceAvailability + resourceType: ResourceType + availability?: ResourceAvailability + isDuplicate?: boolean } export interface Lesson extends CommonEntityFields, ResourceBase { diff --git a/src/utils/helper-functions.tsx b/src/utils/helper-functions.tsx index 90dd12253..e9cc27d52 100644 --- a/src/utils/helper-functions.tsx +++ b/src/utils/helper-functions.tsx @@ -142,7 +142,7 @@ export const getScreenBasedLimit = ( } } -export const ajustColumns = < +export const adjustColumns = < T extends | Cooperation | Offer diff --git a/src/utils/validations/isValidUUID.js b/src/utils/validations/isValidUUID.js new file mode 100644 index 000000000..fbe34a6d4 --- /dev/null +++ b/src/utils/validations/isValidUUID.js @@ -0,0 +1,6 @@ +export const isValidUUID = (uuid) => { + const s = '' + uuid + const regex = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + return regex.test(s) +} diff --git a/tests/unit/components/checkbox-with-tooltip/CheckboxWithTooltip.spec.jsx b/tests/unit/components/checkbox-with-tooltip/CheckboxWithTooltip.spec.jsx index 3123a4812..9c50a64e1 100644 --- a/tests/unit/components/checkbox-with-tooltip/CheckboxWithTooltip.spec.jsx +++ b/tests/unit/components/checkbox-with-tooltip/CheckboxWithTooltip.spec.jsx @@ -3,7 +3,8 @@ import CheckboxWithTooltip from '~/components/checkbox-with-tooltip/CheckboxWith const props = { label: 'Test Checkbox', - tooltipTitle: 'Test Tooltip' + tooltipTitle: 'Test Tooltip', + onChecked: vi.fn() } describe('CheckboxWithTooltip', () => { @@ -25,4 +26,16 @@ describe('CheckboxWithTooltip', () => { expect(checkbox).toBeInTheDocument() expect(tooltip).toBeInTheDocument() }) + + it('calls onChecked callback when checkbox is toggled', () => { + const checkbox = screen.getByLabelText(props.label) + + fireEvent.click(checkbox) + + expect(props.onChecked).toHaveBeenCalledWith(true) + + fireEvent.click(checkbox) + + expect(props.onChecked).toHaveBeenCalledWith(false) + }) }) diff --git a/tests/unit/components/cooperation-section-view/CooperationSectionView.spec.jsx b/tests/unit/components/cooperation-section-view/CooperationSectionView.spec.jsx index f56c8340f..3d599dad4 100644 --- a/tests/unit/components/cooperation-section-view/CooperationSectionView.spec.jsx +++ b/tests/unit/components/cooperation-section-view/CooperationSectionView.spec.jsx @@ -1,12 +1,13 @@ import { render, screen } from '@testing-library/react' import CooperationSectionView from '~/components/cooperation-section-view/CooperationSectionView' +import { ResourcesTypesEnum as ResourceType } from '~/types' describe('CooperationSectionView', () => { const mockSection = { title: 'QuizzesTitle', description: 'Quizzes', - activities: [ + resources: [ { resource: { _id: '662ba5f9f3edc14ca7b2336f', @@ -16,7 +17,7 @@ describe('CooperationSectionView', () => { items: ['656609af8a848ff2202df8d5'], author: '6565fc5a8a848ff2202df766', category: '656609518a848ff2202df8b7', - resourceType: 'quizzes', + resourceType: ResourceType.Quiz, settings: { view: 'Scroll', shuffle: true, @@ -29,20 +30,20 @@ describe('CooperationSectionView', () => { date: '1234' } }, - resourceType: 'quizzes' + resourceType: ResourceType.Quiz } ], _id: '6632264063eb69afaf165c61' } beforeEach(() => { - render() + render() }) it('should render resource title and description', () => { - const title = screen.getByText(mockSection.activities[0].resource.title) + const title = screen.getByText(mockSection.resources[0].resource.title) const description = screen.getByText( - mockSection.activities[0].resource.description + mockSection.resources[0].resource.description ) expect(title).toBeInTheDocument() diff --git a/tests/unit/containers/add-resources/add-attachments/AddAttachments.spec.jsx b/tests/unit/containers/add-resources/add-attachments/AddAttachments.spec.jsx index 874f778a4..e83b69ca0 100644 --- a/tests/unit/containers/add-resources/add-attachments/AddAttachments.spec.jsx +++ b/tests/unit/containers/add-resources/add-attachments/AddAttachments.spec.jsx @@ -2,6 +2,7 @@ import { screen, waitFor } from '@testing-library/react' import AddResources from '~/containers/add-resources/AddResources' import { mockAxiosClient, renderWithProviders } from '~tests/test-utils' import { URLs } from '~/constants/request' +import { ResourcesTabsEnum } from '~/types' import { columns, removeColumnRules @@ -51,7 +52,7 @@ describe('Tests for AddResources container', () => { onAddResources={mockOnAddResources} removeColumnRules={removeColumnRules} requestService={mockRequestService} - resourceType={'attachments'} + resourceTab={ResourcesTabsEnum.Attachments} resources={responseItemsMock} /> ) diff --git a/tests/unit/containers/add-resources/add-lessons/AddLessons.spec.jsx b/tests/unit/containers/add-resources/add-lessons/AddLessons.spec.jsx index 3f312e8ec..7835ab7a5 100644 --- a/tests/unit/containers/add-resources/add-lessons/AddLessons.spec.jsx +++ b/tests/unit/containers/add-resources/add-lessons/AddLessons.spec.jsx @@ -1,6 +1,7 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' import AddResources from '~/containers/add-resources/AddResources' import { URLs } from '~/constants/request' +import { ResourcesTabsEnum } from '~/types' import { columns, removeColumnRules @@ -53,7 +54,7 @@ describe('Tests for AddResources container', () => { onAddResources={mockOnAddResources} removeColumnRules={removeColumnRules} requestService={mockRequestService} - resourceType={'lessons'} + resourceTab={ResourcesTabsEnum.Lessons} resources={responseItemsMock} /> ) diff --git a/tests/unit/containers/add-resources/add-questions/AddQuestions.spec.jsx b/tests/unit/containers/add-resources/add-questions/AddQuestions.spec.jsx index 1f71470a2..9db44cb6e 100644 --- a/tests/unit/containers/add-resources/add-questions/AddQuestions.spec.jsx +++ b/tests/unit/containers/add-resources/add-questions/AddQuestions.spec.jsx @@ -2,6 +2,7 @@ import { screen, waitFor } from '@testing-library/react' import AddResources from '~/containers/add-resources/AddResources' import { mockAxiosClient, renderWithProviders } from '~tests/test-utils' import { URLs } from '~/constants/request' +import { ResourcesTabsEnum } from '~/types' import { columns, removeColumnRules @@ -53,7 +54,7 @@ describe('AddQuestions', () => { onAddResources={mockOnAddResources} removeColumnRules={removeColumnRules} requestService={mockRequestService} - resourceType={'questions'} + resourceTab={ResourcesTabsEnum.Questions} resources={responseItemsMock} /> ) diff --git a/tests/unit/containers/add-resources/add-quizzes/AddQuizzes.spec.jsx b/tests/unit/containers/add-resources/add-quizzes/AddQuizzes.spec.jsx index 4d45a98b0..bbb26063c 100644 --- a/tests/unit/containers/add-resources/add-quizzes/AddQuizzes.spec.jsx +++ b/tests/unit/containers/add-resources/add-quizzes/AddQuizzes.spec.jsx @@ -2,6 +2,7 @@ import { screen, waitFor } from '@testing-library/react' import AddResources from '~/containers/add-resources/AddResources' import { mockAxiosClient, renderWithProviders } from '~tests/test-utils' import { URLs } from '~/constants/request' +import { ResourcesTabsEnum } from '~/types' import { columns, removeColumnRules @@ -49,7 +50,7 @@ describe('AddQuizzes', () => { onAddResources={mockOnAddResources} removeColumnRules={removeColumnRules} requestService={mockRequestService} - resourceType={'quizzes'} + resourceTab={ResourcesTabsEnum.Quizzes} resources={responseItemsMock} /> ) diff --git a/tests/unit/containers/cooperation-details/CooperationActivities.spec.jsx b/tests/unit/containers/cooperation-details/CooperationActivities.spec.jsx deleted file mode 100644 index da73fdcd4..000000000 --- a/tests/unit/containers/cooperation-details/CooperationActivities.spec.jsx +++ /dev/null @@ -1,14 +0,0 @@ -import { screen } from '@testing-library/react' -import CooperationActivities from '~/containers/cooperation-details/cooperation-activities/CooperationActivities' - -import { renderWithProviders } from '~tests/test-utils' - -describe('Cooperation from scratch', () => { - it('should render cooperation from scratch page', () => { - renderWithProviders() - - const scratchContainer = screen.getByTestId('coop-from-scratch') - - expect(scratchContainer).toBeInTheDocument() - }) -}) diff --git a/tests/unit/containers/cooperation-details/AddCourseTemplateModal.spec.jsx b/tests/unit/containers/cooperation-details/add-course-modal-modal/AddCourseTemplateModal.spec.jsx similarity index 100% rename from tests/unit/containers/cooperation-details/AddCourseTemplateModal.spec.jsx rename to tests/unit/containers/cooperation-details/add-course-modal-modal/AddCourseTemplateModal.spec.jsx diff --git a/tests/unit/containers/cooperation-details/ CooperationActivitiesView.spec.jsx b/tests/unit/containers/cooperation-details/cooperation-activities-view/ CooperationActivitiesView.spec.jsx similarity index 85% rename from tests/unit/containers/cooperation-details/ CooperationActivitiesView.spec.jsx rename to tests/unit/containers/cooperation-details/cooperation-activities-view/ CooperationActivitiesView.spec.jsx index c23f2da28..8b697fba3 100644 --- a/tests/unit/containers/cooperation-details/ CooperationActivitiesView.spec.jsx +++ b/tests/unit/containers/cooperation-details/cooperation-activities-view/ CooperationActivitiesView.spec.jsx @@ -1,19 +1,20 @@ import { render, screen, act, fireEvent } from '@testing-library/react' import { describe, it, expect, vi, beforeEach } from 'vitest' import { UserRoleEnum } from '~/types' -import CooperationActivitiesView from '~/containers/cooperation-details/cooperetion-activities-view/CooperationActivitiesView.tsx' + +import CooperationActivitiesView from '~/containers/cooperation-details/cooperation-activities-view/CooperationActivitiesView.tsx' vi.mock('~/components/cooperation-section-view/CooperationSectionView', () => ({ - default: ({ id, item }) => ( -
{item.title}
+ default: ({ item }) => ( +
{item.title}
) })) vi.mock('~/hooks/use-redux', () => ({ useAppSelector: vi.fn().mockReturnValue({ sections: [ - { _id: '1', title: 'Section1' }, - { _id: '2', title: 'Section2' } + { id: '1', title: 'Section1' }, + { id: '2', title: 'Section2' } ], userRole: UserRoleEnum.Tutor }), diff --git a/tests/unit/containers/cooperation-details/cooperation-activities/CooperationActivities.spec.jsx b/tests/unit/containers/cooperation-details/cooperation-activities/CooperationActivities.spec.jsx new file mode 100644 index 000000000..59cf553f1 --- /dev/null +++ b/tests/unit/containers/cooperation-details/cooperation-activities/CooperationActivities.spec.jsx @@ -0,0 +1,202 @@ +import { screen, fireEvent, waitFor } from '@testing-library/react' +import { configureStore } from '@reduxjs/toolkit' + +import { renderWithProviders } from '~tests/test-utils' +import reducer from '~/redux/reducer' +import cooperationsReducer from '~/redux/features/cooperationsSlice' +import snackbarReducer, { openAlert } from '~/redux/features/snackbarSlice' +import { setResourcesAvailability } from '~/redux/features/cooperationsSlice' + +import { snackbarVariants } from '~/constants' +import { ResourcesAvailabilityEnum } from '~/types' +import openIcon from '~/assets/img/cooperation-details/resource-availability/open-icon.svg' + +import CooperationActivities from '~/containers/cooperation-details/cooperation-activities/CooperationActivities' + +let mockUpdateCooperation +const mockSetEditMode = vi.fn() +const mockDispatch = vi.fn() + +const store = configureStore({ + reducer: { + appMain: reducer, + snackbar: snackbarReducer, + cooperations: cooperationsReducer + } +}) + +vi.mock('~/services/cooperation-service', async () => { + const actual = await vi.importActual('~/services/cooperation-service') + const mockUpdateCooperation = vi.fn() + return { + ...actual, + cooperationService: { + updateCooperation: mockUpdateCooperation + } + } +}) + +vi.mock('~/hooks/use-axios', async () => { + const actual = await vi.importActual('~/hooks/use-axios') + return { + ...actual, + useAxios: vi.fn(() => ({ + fetchData: vi.fn() + })) + } +}) + +vi.mock('~/redux/features/snackbarSlice', async () => { + const actual = await vi.importActual('~/redux/features/snackbarSlice') + return { + ...actual, + openAlert: vi.fn() + } +}) + +vi.mock('~/hooks/use-redux', async () => { + const actual = await vi.importActual('~/hooks/use-redux') + return { + ...actual, + useAppDispatch: () => mockDispatch, + useAppSelector: vi.fn(() => ({ + sections: [], + resourcesAvailability: ResourcesAvailabilityEnum.OpenAll + })) + } +}) + +vi.mock('~/components/app-select/AppSelect', () => ({ + __esModule: true, + default: ({ setValue, ...props }) => ( + { + setValue(JSON.parse(e.target.value)) + }} + {...props} + /> + ) +})) + +describe('CooperationActivities', () => { + beforeEach(async () => { + renderWithProviders( + , + { store } + ) + }) + + afterEach(() => { + vi.clearAllMocks() + mockDispatch.mockReset() + }) + + it('should render cooperation activities page', () => { + expect( + screen.getByText( + /cooperationdetailspage\.publishcooperationdetailspage\.select\.openall/i + ) + ).toBeInTheDocument() + }) + + it('should display "Save" and "Cancel" buttons', () => { + expect(screen.getByText('common.save')).toBeInTheDocument() + expect(screen.getByText('common.cancel')).toBeInTheDocument() + }) + + it('should display success message on successful update', async () => { + const { cooperationService } = await import( + '~/services/cooperation-service' + ) + cooperationService.updateCooperation.mockResolvedValueOnce({}) + + const saveButton = screen.getByText('common.save') + fireEvent.click(saveButton) + + await waitFor(() => { + expect(openAlert).toHaveBeenCalledWith({ + severity: snackbarVariants.success, + message: 'cooperationsPage.acceptModal.successMessage' + }) + }) + + it('should toggle edit mode on successful update', async () => { + const { cooperationService } = await import( + '~/services/cooperation-service' + ) + cooperationService.updateCooperation.mockResolvedValueOnce({}) + + const saveButton = screen.getByText('common.save') + fireEvent.click(saveButton) + + await waitFor(() => { + expect(mockUpdateCooperation).toHaveBeenCalledTimes(1) + expect(mockUpdateCooperation).toHaveBeenCalledWith(expect.any(Function)) + }) + }) + }) + + it('should display error message when updateCooperationSection fails with specific error', async () => { + const { cooperationService } = await import( + '~/services/cooperation-service' + ) + + const mockError = { + response: { + data: { + message: 'An error occurred' + } + } + } + + cooperationService.updateCooperation.mockRejectedValueOnce(mockError) + + const saveButton = screen.getByText('common.save') + fireEvent.click(saveButton) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledTimes(1) + expect(openAlert).toHaveBeenCalledTimes(1) + expect(openAlert).toHaveBeenCalledWith({ + severity: snackbarVariants.error, + message: { + text: 'errors.UNKNOWN_ERROR', + options: { + message: '' + } + } + }) + }) + }) + + it('should display open icon when resourcesAvailability is OpenAll', () => { + const { getState } = store + const initialResourcesAvailability = + getState().cooperations.resourcesAvailability + + expect(initialResourcesAvailability).toBe(ResourcesAvailabilityEnum.OpenAll) + + const imgElement = screen.getByAltText('resource icon') + expect(imgElement).toHaveAttribute('src', openIcon) + }) + + it('should call handleResourcesAvailabilityChange when a new availability option is selected', async () => { + const elementAppSelect = screen.getByTestId('mock-AppSelect') + const selectedAvailability = { + status: ResourcesAvailabilityEnum.OpenManually + } + + fireEvent.change(elementAppSelect, { + target: { value: JSON.stringify(selectedAvailability) } + }) + + expect(mockDispatch).toHaveBeenCalledWith({ + type: setResourcesAvailability.type, + payload: selectedAvailability + }) + }) +}) diff --git a/tests/unit/containers/course-section/CourseSectionContainer.spec.constants.js b/tests/unit/containers/course-section/CourseSectionContainer.spec.constants.js new file mode 100644 index 000000000..f615e06f4 --- /dev/null +++ b/tests/unit/containers/course-section/CourseSectionContainer.spec.constants.js @@ -0,0 +1,71 @@ +import { ResourcesTypesEnum as ResourceType } from '~/types' + +export const mockedSectionData = { + id: 1, + title: 'Title', + description: 'Description', + resources: [ + { + resource: { + availability: { + status: 'open', + date: null + }, + _id: '64cd12f1fad091e0sfe12134', + id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', + title: 'Lesson1', + author: 'some author', + content: 'Content', + description: 'Description', + attachments: [], + category: null, + resourceType: ResourceType.Lesson + }, + resourceType: ResourceType.Lesson + }, + { + resource: { + availability: { + status: 'open', + date: null + }, + _id: '64fb2c33eba89699411d22bb', + id: '9b2e3d7e-1c4b-4f3b-8f8e-2d3b2c3d4e5f', + title: 'Quiz', + description: '', + items: [], + author: '648afee884936e09a37deaaa', + category: { id: '64fb2c33eba89699411d22bb', name: 'Music' }, + createdAt: '2023-09-08T14:14:11.373Z', + updatedAt: '2023-09-08T14:14:11.373Z', + resourceType: ResourceType.Quiz + }, + resourceType: ResourceType.Quiz + }, + { + resource: { + availability: { + status: 'open', + date: null + }, + _id: '64cd12f1fad091e0ee719830', + id: 'd2c3b4e5-6f7a-8b9c-0d1e-2f3b4c5d6e7f', + author: '6494128829631adbaf5cf615', + fileName: 'spanish.pdf', + link: 'link', + category: { id: '64fb2c33eba89699411d22bb', name: 'History' }, + description: 'Mock description for attachments', + size: 100, + createdAt: '2023-07-25T13:12:12.998Z', + updatedAt: '2023-07-25T13:12:12.998Z', + resourceType: ResourceType.Attachment + }, + resourceType: ResourceType.Attachment + } + ] +} + +export const mockedUpdatedResources = [ + { _id: 'resource1', resourceType: ResourceType.Lesson }, + { _id: 'resource2', resourceType: ResourceType.Quiz } +] diff --git a/tests/unit/containers/course-section/CourseSectionContainer.spec.jsx b/tests/unit/containers/course-section/CourseSectionContainer.spec.jsx index e182dd26a..094654d75 100644 --- a/tests/unit/containers/course-section/CourseSectionContainer.spec.jsx +++ b/tests/unit/containers/course-section/CourseSectionContainer.spec.jsx @@ -1,83 +1,77 @@ +import { + screen, + act, + fireEvent, + waitFor, + cleanup +} from '@testing-library/react' import { renderWithProviders } from '~tests/test-utils' -import { screen, fireEvent, waitFor } from '@testing-library/react' -import CourseSectionContainer from '~/containers/course-section/CourseSectionContainer' +import { CourseResourceEventType } from '~/types' +import { + mockedSectionData, + mockedUpdatedResources +} from '~tests/unit/containers/course-section/CourseSectionContainer.spec.constants' +import { authRoutes } from '~/router/constants/authRoutes' +import { resourceNavigationMap } from '~/containers/course-section/CourseSectionContainer.constants' -const mockedSectionData = { - id: 1, - title: 'Title', - description: 'Description', - lessons: [ - { - _id: '1', - title: 'Lesson1', - author: 'some author', - content: 'Content', - description: 'Description', - attachments: [], - category: null, - resourceType: 'lessons' - } - ], - quizzes: [ - { - _id: '64fb2c33eba89699411d22bb', - title: 'Quiz', - description: '', - items: [], - author: '648afee884936e09a37deaaa', - category: { id: '64fb2c33eba89699411d22bb', name: 'Music' }, - createdAt: '2023-09-08T14:14:11.373Z', - updatedAt: '2023-09-08T14:14:11.373Z', - resourceType: 'quizzes' - } - ], - attachments: [ - { - _id: '64cd12f1fad091e0ee719830', - author: '6494128829631adbaf5cf615', - fileName: 'spanish.pdf', - link: 'link', - category: { id: '64fb2c33eba89699411d22bb', name: 'History' }, - description: 'Mock description for attachments', - size: 100, - createdAt: '2023-07-25T13:12:12.998Z', - updatedAt: '2023-07-25T13:12:12.998Z', - resourceType: 'attachments' - } - ], - order: ['1', '64fb2c33eba89699411d22bb', '64cd12f1fad091e0ee719830'] -} +import CourseSectionContainer from '~/containers/course-section/CourseSectionContainer' const mockedHandleSectionInputChange = vi.fn() -const mockedHandleSectionNonInputChange = vi.fn() -const mockedHandleSectionResourcesOrder = vi.fn() - -const mockedSections = Array(2) - .fill() - .map((_, index) => ({ - ...mockedSectionData, - _id: `${index}`, - title: `${mockedSectionData.title}${index}`, - description: `${mockedSectionData.description}${index}` - })) - -const mockedSetSectionItems = vi.fn() +const mockedResourceEventHandler = vi.fn() +const mockedSectionEventHandler = vi.fn() + +vi.mock( + '~/containers/course-section/resources-list/ResourcesList', + async () => { + const ResourcesList = ( + await vi.importActual( + '~/containers/course-section/resources-list/ResourcesList' + ) + ).default + return { + __esModule: true, + default: (props) => ( + <> + + { + const parsed = JSON.parse(e.target.value) + if (props[parsed.event]) { + props[parsed.event](parsed.payload) + } + }} + /> + + ) + } + } +) describe('CourseSectionContainer tests', () => { - beforeEach(async () => { - await waitFor(() => { - renderWithProviders( - - ) - }) + beforeEach(() => { + renderWithProviders( + + ) + }) + + afterEach(() => { + cleanup() + vi.resetAllMocks() }) it('should render inputs for title and description', () => { @@ -88,11 +82,111 @@ describe('CourseSectionContainer tests', () => { expect(labelInput).toBeInTheDocument() }) + it('should display default new description when description is not provided', () => { + const sectionDataWithoutDescription = { ...mockedSectionData } + delete sectionDataWithoutDescription.description + + cleanup() + renderWithProviders( + + ) + const defaultDescription = screen.getByText( + /course\.coursesection\.defaultnewdescription/i + ) + + expect(defaultDescription).toBeInTheDocument() + }) + + it('should call handleSectionInputChange with the correct arguments when the title input is changed', () => { + const titleInput = screen.getByDisplayValue(mockedSectionData.title) + act(() => + fireEvent.change(titleInput, { + target: { + value: 'New title' + } + }) + ) + act(() => fireEvent.blur(titleInput)) + + expect(mockedHandleSectionInputChange).toHaveBeenCalledWith( + mockedSectionData.id, + 'title', + 'New title' + ) + }) + + it('should call handleSectionInputChange with the correct arguments when the description input is blurred', () => { + const descriptionInput = screen.getByDisplayValue( + mockedSectionData.description + ) + act(() => + fireEvent.change(descriptionInput, { + target: { + value: 'New description' + } + }) + ) + act(() => fireEvent.blur(descriptionInput)) + + expect(mockedHandleSectionInputChange).toHaveBeenCalledWith( + mockedSectionData.id, + 'description', + 'New description' + ) + }) + + it('should render availability status for each resource', async () => { + await waitFor(() => { + const allMenuAvailabilityStatus = screen.getAllByTestId('app-select') + allMenuAvailabilityStatus.forEach((resource, index) => { + const resourceStatus = + mockedSectionData.resources[index].resource.availability.status + expect(resource).toHaveValue(resourceStatus) + }) + }) + }) + + it('should call handleSectionInputChange with the correct arguments when the resource status is changed', async () => { + const activityIndexToChange = 1 + await waitFor(() => { + const allMenuAvailabilityStatus = screen.getAllByTestId('app-select') + const menuAvailabilityToChange = + allMenuAvailabilityStatus[activityIndexToChange] + + act(() => + fireEvent.change(menuAvailabilityToChange, { + target: { value: 'closed' } + }) + ) + act(() => fireEvent.blur(menuAvailabilityToChange)) + }) + + expect(mockedResourceEventHandler).toHaveBeenCalledTimes(1) + expect(mockedResourceEventHandler).toHaveBeenCalledWith({ + resource: { + availability: { + date: null, + status: 'closed' + } + }, + resourceId: + mockedSectionData.resources[activityIndexToChange].resource.id, + sectionId: 1, + type: 'resourceUpdated' + }) + }) + it('should render menu button and menu', () => { const addResourcesBtn = screen.getByText( 'course.courseSection.addResourceBtn' ) - fireEvent.click(addResourcesBtn) + act(() => fireEvent.click(addResourcesBtn)) const menuList = screen.getByRole('menu') expect(menuList).toBeInTheDocument() @@ -103,11 +197,9 @@ describe('CourseSectionContainer tests', () => { 'course.courseSection.addResourceBtn' ) - waitFor(() => fireEvent.click(addResourcesBtn)) - + act(() => fireEvent.click(addResourcesBtn)) const menuListItem = screen.getAllByRole('menuitem')[0] - - waitFor(() => fireEvent.click(menuListItem)) + act(() => fireEvent.click(menuListItem)) expect(menuListItem).not.toBeVisible() }) @@ -117,18 +209,21 @@ describe('CourseSectionContainer tests', () => { 'course.courseSection.addResourceBtn' ) const hideBtn = screen.getAllByRole('button')[0] - fireEvent.click(hideBtn) + act(() => fireEvent.click(hideBtn)) expect(addResourcesBtn).not.toBeVisible() }) - it('should set section items on delete', async () => { + it('should call event handler with properly type when the delete button is clicked on section', () => { const deleteMenu = screen.getByTestId('MoreVertIcon').parentElement - fireEvent.click(deleteMenu) + act(() => fireEvent.click(deleteMenu)) const deleteButton = screen.getByTestId('DeleteOutlineIcon').parentElement - fireEvent.click(deleteButton) + act(() => fireEvent.click(deleteButton)) - expect(mockedSetSectionItems).toHaveBeenCalled() + expect(mockedSectionEventHandler).toHaveBeenCalledWith({ + sectionId: 1, + type: 'sectionRemoved' + }) }) it('should show add lessons modal', () => { @@ -136,14 +231,11 @@ describe('CourseSectionContainer tests', () => { 'course.courseSection.addResourceBtn' ) - waitFor(() => fireEvent.click(addResourcesBtn)) - + act(() => fireEvent.click(addResourcesBtn)) const addLessonBtn = screen.getByText( 'course.courseSection.resourcesMenu.lessonMenuItem' ).parentElement - - waitFor(() => fireEvent.click(addLessonBtn)) - + act(() => fireEvent.click(addLessonBtn)) const addLessonModal = screen.getByText('myResourcesPage.lessons.add') expect(addLessonModal).toBeInTheDocument() @@ -154,14 +246,11 @@ describe('CourseSectionContainer tests', () => { 'course.courseSection.addResourceBtn' ) - waitFor(() => fireEvent.click(addResourcesBtn)) - + act(() => fireEvent.click(addResourcesBtn)) const addQuizBtn = screen.getByText( 'course.courseSection.resourcesMenu.quizMenuItem' ).parentElement - - waitFor(() => fireEvent.click(addQuizBtn)) - + act(() => fireEvent.click(addQuizBtn)) const addQuizModal = screen.getByText('myResourcesPage.quizzes.add') expect(addQuizModal).toBeInTheDocument() @@ -172,14 +261,11 @@ describe('CourseSectionContainer tests', () => { 'course.courseSection.addResourceBtn' ) - waitFor(() => fireEvent.click(addResourcesBtn)) - + act(() => fireEvent.click(addResourcesBtn)) const addAttachmentBtn = screen.getByText( 'course.courseSection.resourcesMenu.attachmentMenuItem' ).parentElement - - waitFor(() => fireEvent.click(addAttachmentBtn)) - + act(() => fireEvent.click(addAttachmentBtn)) const addAttachmentModal = screen.getByText( 'myResourcesPage.attachments.add' ) @@ -187,36 +273,191 @@ describe('CourseSectionContainer tests', () => { expect(addAttachmentModal).toBeInTheDocument() }) - it('should delete lesson', () => { - waitFor(() => { + it('should delete lesson and call event handler with properly type when the delete button is clicked on lesson', async () => { + await waitFor(() => { const lessonDelete = screen.getAllByTestId('CloseIcon')[0].parentElement + act(() => fireEvent.click(lessonDelete)) + }) + expect(mockedResourceEventHandler).toHaveBeenCalledWith({ + resourceId: mockedSectionData.resources[0].resource.id, + sectionId: 1, + type: 'resourceRemoved' + }) + }) - fireEvent.click(lessonDelete) + it('should delete quiz and call event handler with properly type when the delete button is clicked on quiz', async () => { + await waitFor(() => { + const quizDelete = screen.getAllByTestId('CloseIcon')[1].parentElement + act(() => fireEvent.click(quizDelete)) }) + expect(mockedResourceEventHandler).toHaveBeenCalledWith({ + resourceId: mockedSectionData.resources[1].resource.id, + sectionId: 1, + type: 'resourceRemoved' + }) + }) - waitFor(() => { - expect(mockedHandleSectionNonInputChange).toHaveBeenCalled() + it('should delete attachment and call event handler with properly type when the delete button is clicked on attachment', async () => { + await waitFor(() => { + const attachmentDelete = + screen.getAllByTestId('CloseIcon')[2].parentElement + act(() => fireEvent.click(attachmentDelete)) + }) + expect(mockedResourceEventHandler).toHaveBeenCalledWith({ + resourceId: mockedSectionData.resources[2].resource.id, + sectionId: 1, + type: 'resourceRemoved' }) }) +}) - it('it should delete quiz', async () => { - await waitFor(async () => { - const quizDelete = screen.getAllByTestId('CloseIcon')[1].parentElement +describe('Testing CourseSectionContainer Event Handlers', () => { + const mockSectionId = mockedSectionData.id + beforeEach(() => { + renderWithProviders( + + ) + }) + + afterEach(() => { + cleanup() + vi.resetAllMocks() + }) + + it('should handle resource update event [CourseResourceEventType.ResourceUpdated]', async () => { + const updatedResource = mockedSectionData.resources[0].resource + const newAvailability = 'closed' - fireEvent.click(quizDelete) + await waitFor(() => { + const availabilitySelect = screen.getAllByTestId('app-select')[0] + fireEvent.change(availabilitySelect, { + target: { value: newAvailability } + }) + fireEvent.blur(availabilitySelect) }) - expect(mockedHandleSectionNonInputChange).toHaveBeenCalled() + expect(mockedResourceEventHandler).toHaveBeenCalledTimes(1) + expect(mockedResourceEventHandler).toHaveBeenCalledWith({ + type: CourseResourceEventType.ResourceUpdated, + sectionId: mockSectionId, + resourceId: updatedResource.id, + resource: { + availability: { + ...updatedResource.availability, + status: newAvailability + } + } + }) + }) + + it('should handle resource order change event [CourseResourceEventType.ResourcesOrderChange]', async () => { + fireEvent.change(screen.getByTestId('mock-ResourcesList'), { + target: { + value: JSON.stringify({ + event: 'sortResources', + payload: { + type: CourseResourceEventType.ResourcesOrderChange, + sectionId: mockSectionId, + resources: mockedUpdatedResources + } + }) + } + }) + + await waitFor(() => { + expect(mockedResourceEventHandler).toHaveBeenCalledTimes(1) + expect(mockedResourceEventHandler).toHaveBeenCalledWith( + expect.objectContaining({ + type: CourseResourceEventType.ResourcesOrderChange, + sectionId: mockSectionId, + resources: expect.objectContaining({ + resources: expect.arrayContaining([ + expect.objectContaining({ + _id: expect.any(String), + resourceType: expect.any(String) + }) + ]) + }) + }) + ) + }) + }) + + it('should handle resource remove event [CourseResourceEventType.ResourceRemoved]', async () => { + fireEvent.change(screen.getByTestId('mock-ResourcesList'), { + target: { + value: JSON.stringify({ + event: 'deleteResource', + payload: { + type: CourseResourceEventType.ResourceRemoved, + sectionId: mockSectionId, + resourceId: mockedSectionData.resources[0].id + } + }) + } + }) + + await waitFor(() => { + expect(mockedResourceEventHandler).toHaveBeenCalledTimes(1) + expect(mockedResourceEventHandler).toHaveBeenCalledWith( + expect.objectContaining({ + type: CourseResourceEventType.ResourceRemoved, + sectionId: mockSectionId, + resourceId: mockedSectionData.resources[0].id + }) + ) + }) + }) + + it('should handle edit resource event when resourceType is not Attachment', async () => { + const resource = mockedSectionData.resources[0].resource + const editResourceSpy = vi + .spyOn(window, 'open') + .mockImplementation(() => ({ focus: vi.fn() })) + + fireEvent.change(screen.getByTestId('mock-ResourcesList'), { + target: { + value: JSON.stringify({ + event: 'editResource', + payload: resource + }) + } + }) + + await waitFor(() => { + expect(editResourceSpy).toHaveBeenCalledTimes(1) + expect(editResourceSpy).toHaveBeenCalledWith( + expect.stringContaining( + authRoutes.myResources[resourceNavigationMap[resource.resourceType]] + .path + ), + '_blank' + ) + }) }) - it('it should delete attachment', async () => { - await waitFor(async () => { - const attachmentDelete = (await screen.findAllByTestId('CloseIcon'))[2] - .parentElement + it('should handle edit resource event when resourceType is Attachment', async () => { + const resource = mockedSectionData.resources[2].resource - fireEvent.click(attachmentDelete) + fireEvent.change(screen.getByTestId('mock-ResourcesList'), { + target: { + value: JSON.stringify({ + event: 'editResource', + payload: resource + }) + } }) - expect(mockedHandleSectionNonInputChange).toHaveBeenCalled() + await waitFor(() => { + expect( + screen.getByText('myResourcesPage.attachments.edit') + ).toBeInTheDocument() + }) }) }) diff --git a/tests/unit/containers/course-section/resource-item/ResourceItem.spec.constants.js b/tests/unit/containers/course-section/resource-item/ResourceItem.spec.constants.js new file mode 100644 index 000000000..8d3153cfc --- /dev/null +++ b/tests/unit/containers/course-section/resource-item/ResourceItem.spec.constants.js @@ -0,0 +1,51 @@ +import { ResourcesTypesEnum as ResourceType } from '~/types' + +export const mockedLessonDataOriginal = { + _id: '66b67d84b58ba31be667ee2d', + author: '6658f73f93885febb491e08b', + title: 'Exploring Systems of Linear Equations', + description: + 'Students will learn to solve systems of linear equations using different methods, including graphing, substitution, and elimination.', + content: '

Lesson Plan:

', + attachments: [], + category: '6684175179e5232bce4579ed', + resourceType: ResourceType.Lesson, + availability: null +} + +export const mockedQuizDataDuplicate = { + _id: '66b67e2ab58ba31be667ee4c', + title: 'Quiz: Basics of Multiplication', + description: + 'A quiz to test the understanding of basic multiplication concepts.', + items: ['66b67e02b58ba31be667ee43'], + author: '6658f73f93885febb491e08b', + category: '6684175179e5232bce4579ed', + resourceType: ResourceType.Quiz, + settings: { + view: 'Scroll', + shuffle: false, + pointValues: false, + scoredResponses: false, + correctAnswers: false + }, + availability: { + status: 'openFrom', + date: '2022-05-01T00:00:00.000Z' + }, + isDuplicate: true +} + +export const mockedAttachmentDataOriginal = { + _id: '66b67eafb58ba31be667ee83', + author: '6658f73f93885febb491e08b', + fileName: 'Exploring Systems of Linear Equations.png', + link: '1723236050559-Exploring Systems of Linear Equations.png', + size: 39340, + category: '6684175179e5232bce4579ed', + resourceType: ResourceType.Attachment, + availability: { + status: 'open', + date: null + } +} diff --git a/tests/unit/containers/course-section/resource-item/ResourceItem.spec.jsx b/tests/unit/containers/course-section/resource-item/ResourceItem.spec.jsx index 1199cf9b3..e689a97cc 100644 --- a/tests/unit/containers/course-section/resource-item/ResourceItem.spec.jsx +++ b/tests/unit/containers/course-section/resource-item/ResourceItem.spec.jsx @@ -1,40 +1,65 @@ import { renderWithProviders } from '~tests/test-utils' -import { fireEvent, screen } from '@testing-library/react' +import { fireEvent, screen, waitFor } from '@testing-library/react' -import ResourceItem from '~/containers/course-section/resource-item/ResourceItem' -import { ResourcesTabsEnum as ResourcesTypes } from '~/types' - -const mockedLessonData = { - _id: '1', - title: 'Lesson1', - author: 'some author', - content: 'Content', - description: 'Description', - attachments: [], - category: null, - resourceType: ResourcesTypes.Lessons -} +import { + mockedLessonDataOriginal, + mockedQuizDataDuplicate, + mockedAttachmentDataOriginal +} from '~tests/unit/containers/course-section/resource-item/ResourceItem.spec.constants' -const mockedSetResourceAvailability = vi.fn() +import ResourceItem from '~/containers/course-section/resource-item/ResourceItem' -const mockedDeleteFunc = vi.fn() -const mockedEditFunc = vi.fn() +const mockDeleteResource = vi.fn() +const mockEditResource = vi.fn() +const mockUpdateAvailability = vi.fn() + +vi.mock('@mui/x-date-pickers/LocalizationProvider', async () => { + const actual = await vi.importActual( + '@mui/x-date-pickers/LocalizationProvider' + ) + return { + ...actual, + LocalizationProvider: ({ children }) => ( +
{children}
+ ) + } +}) -describe('new course section ResourceItem tests', () => { +vi.mock('@mui/x-date-pickers/DatePicker', () => ({ + __esModule: true, + DatePicker: ({ onChange }) => ( + { + const newDate = new Date(e.target.value) + onChange(newDate) + }} + type='date' + /> + ) +})) + +describe('ResourceItem tests', () => { beforeEach(() => { renderWithProviders( ) }) it('should render added resource', () => { - const resourceTitle = screen.getByText(mockedLessonData.title) + const resourceTitle = screen.getByText(mockedLessonDataOriginal.title) + const resourceDescription = screen.getByText( + mockedLessonDataOriginal.description + ) + expect(resourceTitle).toBeInTheDocument() + expect(resourceDescription).toBeInTheDocument() }) it('should display lesson icon', () => { @@ -47,32 +72,161 @@ describe('new course section ResourceItem tests', () => { fireEvent.click(deleteButton) - expect(mockedDeleteFunc).toHaveBeenCalledTimes(1) + expect(mockDeleteResource).toHaveBeenCalledTimes(1) }) - it('should call edit resource function', () => { - const editButton = screen.getByLabelText('edit') + it('should call link resource function', () => { + const linkButton = screen.getByLabelText('link') + const iconLink = screen.getByTestId('LinkRoundedIcon') - fireEvent.click(editButton) + fireEvent.click(linkButton) + + expect(iconLink).toBeInTheDocument() + expect(mockEditResource).toHaveBeenCalledTimes(1) + }) - expect(mockedEditFunc).toHaveBeenCalledTimes(1) + it('should set resourceAvailabilityStatus to Open when resourceAvailability is null or undefined', () => { + const availabilityIcon = screen.getByRole('img', { + src: '/src/assets/img/cooperation-details/resource-availability/open-icon.svg' + }) + expect(availabilityIcon).toBeInTheDocument() }) }) -describe('should render quiz component', () => { - it('should display quiz icon', () => { - mockedLessonData.resourceType = ResourcesTypes.Quizzes +describe('ResourceItem tests with isView prop', () => { + beforeEach(() => { + renderWithProviders( + + ) + }) + it('should render availability icon', () => { + const availabilityIcon = screen.getByRole('img', { + name: /resource icon/i + }) + expect(availabilityIcon).toBeInTheDocument() + }) + + it('should not render link and delete icon', () => { + expect(screen.queryByLabelText('delete')).not.toBeInTheDocument() + expect(screen.queryByLabelText('link')).not.toBeInTheDocument() + }) + + it('should properly render availability status and icon', () => { + const option = screen.getByRole('img', { + src: '/src/assets/img/cooperation-details/resource-availability/open-icon.svg' + }) + + expect(option).toBeInTheDocument() + }) +}) + +describe('ResourceItem tests with isCooperation prop', () => { + beforeEach(() => { renderWithProviders( ) + }) + + it('should render availability selection', () => { + const availabilitySelection = screen.getByTestId('ArrowDropDownIcon') + expect(availabilitySelection).toBeInTheDocument() + }) + + it('should properly render availability status and icon', () => { + const availabilitySelect = screen.getByTestId('app-select') + const option = screen.getByRole('img', { + src: '/src/assets/img/cooperation-details/resource-availability/open-icon.svg' + }) + + expect(availabilitySelect).toBeInTheDocument() + expect(option).toBeInTheDocument() + expect(availabilitySelect.value).toBe('openFrom') + }) + it('should call setOpenFromDate when DatePicker value changes', async () => { + const datePickerInput = screen.getByTestId('mock-DatePicker') + fireEvent.change(datePickerInput, { target: { value: '2025-08-16' } }) + + await waitFor(() => { + expect(mockUpdateAvailability).toHaveBeenCalledWith( + mockedQuizDataDuplicate, + expect.objectContaining({ + status: 'openFrom', + date: '2025-08-16T00:00:00.000Z' + }) + ) + }) + }) + + it('should call updateAvailability when availability status changes', () => { + const availabilitySelect = screen.getByTestId('app-select') + fireEvent.change(availabilitySelect, { target: { value: 'open' } }) + + expect(mockUpdateAvailability).toHaveBeenCalledWith( + mockedQuizDataDuplicate, + expect.objectContaining({ + status: 'open', + date: null + }) + ) + }) +}) + +describe('ResourceItem tests when isDuplicate=true and resourceType quiz', () => { + beforeEach(() => { + renderWithProviders( + + ) + }) + + it('should display quiz icon', () => { const quizIcon = screen.getByTestId('NoteAltOutlinedIcon') expect(quizIcon).toBeInTheDocument() }) + + it('should call edit resource function', () => { + const editButton = screen.getByLabelText('edit') + + fireEvent.click(editButton) + + expect(mockEditResource).toHaveBeenCalledTimes(2) + }) +}) + +describe('ResourceItem tests when resourceType attachment', () => { + beforeEach(() => { + renderWithProviders( + + ) + }) + + it('should properly display attachment', () => { + const attachmentItem = screen.getByText('png') + + expect(attachmentItem).toBeInTheDocument() + }) }) diff --git a/tests/unit/containers/course-section/resources-list/ResourcesList.spec.jsx b/tests/unit/containers/course-section/resources-list/ResourcesList.spec.jsx index 7e1a2fba8..e27902b02 100644 --- a/tests/unit/containers/course-section/resources-list/ResourcesList.spec.jsx +++ b/tests/unit/containers/course-section/resources-list/ResourcesList.spec.jsx @@ -2,7 +2,7 @@ import { renderWithProviders } from '~tests/test-utils' import { screen } from '@testing-library/react' import ResourcesList from '~/containers/course-section/resources-list/ResourcesList' -import { ResourcesTabsEnum as ResourcesTypes } from '~/types' +import { ResourcesTypesEnum as ResourceType } from '~/types' const mockedLessonData = [ { @@ -13,7 +13,7 @@ const mockedLessonData = [ description: 'Description', attachments: [], category: null, - resourceType: ResourcesTypes.Lessons + resourceType: ResourceType.Lesson }, { _id: '2', @@ -23,7 +23,7 @@ const mockedLessonData = [ description: 'Description', attachments: [], category: null, - resourceType: ResourcesTypes.Lessons + resourceType: ResourceType.Lesson } ] diff --git a/tests/unit/containers/course-sections-list/CourseSectionsList.spec.jsx b/tests/unit/containers/course-sections-list/CourseSectionsList.spec.jsx index 36c9c7eaa..ff02dcd57 100644 --- a/tests/unit/containers/course-sections-list/CourseSectionsList.spec.jsx +++ b/tests/unit/containers/course-sections-list/CourseSectionsList.spec.jsx @@ -4,11 +4,11 @@ import { renderWithProviders } from '~tests/test-utils' import CourseSectionsList from '~/containers/course-sections-list/CourseSectionsList' import AddCourseTemplateModal from '~/containers/cooperation-details/add-course-modal-modal/AddCourseTemplateModal' -const mockedHandleSectionInputChange = vi.fn() -const mockedHandleSectionNonInputChange = vi.fn() -const mockedHandleSectionResourcesOrder = vi.fn() -const mockedAddNewSection = vi.fn() -const mockedSetSections = vi.fn() +import { ResourcesTypesEnum as ResourceType } from '~/types' + +const mockedHandleSectionChange = vi.fn() +const mockedResourceEventHandler = vi.fn() +const mockedSectionEventHandler = vi.fn() const mockedCourseSectionData = Array(5) .fill() @@ -16,47 +16,59 @@ const mockedCourseSectionData = Array(5) id: `${index + 1}`, title: `Title ${index + 1}`, description: `Description ${index + 1}`, - lessons: [ + resources: [ { - _id: '1', - title: 'Lesson1', - author: 'some author', - content: 'Content', - description: 'Description', - attachments: [], - category: null, - resourceType: 'lessons' - } - ], - quizzes: [ + resource: { + availability: { + status: 'open', + date: null + }, + _id: '64cd12f1fad091e0sfe12134', + title: 'Lesson1', + author: 'some author', + content: 'Content', + description: 'Description', + attachments: [], + category: null + }, + resourceType: ResourceType.Lesson + }, { - _id: '64fb2c33eba89699411d22bb', - title: 'Quiz', - description: '', - items: [], - author: '648afee884936e09a37deaaa', - category: { id: '64fb2c33eba89699411d22bb', name: 'Music' }, - createdAt: '2023-09-08T14:14:11.373Z', - updatedAt: '2023-09-08T14:14:11.373Z', - resourceType: 'quizzes' - } - ], - attachments: [ + resource: { + availability: { + status: 'open', + date: null + }, + _id: '64fb2c33eba89699411d22bb', + title: 'Quiz', + description: '', + items: [], + author: '648afee884936e09a37deaaa', + category: { id: '64fb2c33eba89699411d22bb', name: 'Music' }, + createdAt: '2023-09-08T14:14:11.373Z', + updatedAt: '2023-09-08T14:14:11.373Z' + }, + resourceType: ResourceType.Quiz + }, { - _id: '64cd12f1fad091e0ee719830', - author: '6494128829631adbaf5cf615', - fileName: 'spanish.pdf', - link: 'link', - category: { id: '64fb2c33eba89699411d22bb', name: 'History' }, - description: 'Mock description for attachments', - size: 100, - createdAt: '2023-07-25T13:12:12.998Z', - updatedAt: '2023-07-25T13:12:12.998Z', - resourceType: 'attachments' + resource: { + availability: { + status: 'open', + date: null + }, + _id: '64cd12f1fad091e0ee719830', + author: '6494128829631adbaf5cf615', + fileName: 'spanish.pdf', + link: 'link', + category: { id: '64fb2c33eba89699411d22bb', name: 'History' }, + description: 'Mock description for attachments', + size: 100, + createdAt: '2023-07-25T13:12:12.998Z', + updatedAt: '2023-07-25T13:12:12.998Z' + }, + resourceType: ResourceType.Attachment } - ], - order: ['1', '64fb2c33eba89699411d22bb', '64cd12f1fad091e0ee719830'], - activities: [] + ] })) vi.mock( @@ -121,18 +133,16 @@ vi.mock('~/hooks/use-menu', async (importOriginal) => { } }) -describe.skip('CourseSectionsList tests', () => { +describe('CourseSectionsList tests', () => { beforeEach(async () => { await waitFor(() => { renderWithProviders( ) }) @@ -157,9 +167,10 @@ describe.skip('CourseSectionsList tests', () => { const deleteMenuButton = screen.getByTestId('DeleteOutlineIcon') expect(deleteMenuButton).toBeInTheDocument() waitFor(() => fireEvent.click(deleteMenuButton)) - expect(mockedSetSections).toHaveBeenCalledWith( - mockedCourseSectionData.slice(1) - ) + expect(mockedSectionEventHandler).toHaveBeenCalledWith({ + sectionId: '1', + type: 'sectionRemoved' + }) }) it('should render cooperation menu on click "Add activity"', () => { @@ -191,13 +202,16 @@ describe.skip('CourseSectionsList tests', () => { ) }) - it('should call addNewSection when "Module" (handleMenuItemClick) is clicked in the "Add activity" menu', () => { - const itemIndex = 3 + it('should call event handler with proper type when "Module" is clicked in the "Add activity" menu', () => { + const itemIndex = 1 const addActivityButton = screen.getAllByTestId('Add activity')[itemIndex] waitFor(() => fireEvent.click(addActivityButton)) const addModuleButton = screen.getAllByTestId('Crop75Icon')[itemIndex] waitFor(() => fireEvent.click(addModuleButton)) - expect(mockedAddNewSection).toHaveBeenCalledWith(itemIndex) + expect(mockedSectionEventHandler).toHaveBeenCalledWith({ + index: 1, + type: 'sectionAdded' + }) }) }) @@ -206,13 +220,11 @@ describe('CourseSectionsList test when prop items is empty', () => { await waitFor(() => { renderWithProviders( ) }) diff --git a/tests/unit/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.spec.constants.js b/tests/unit/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.spec.constants.js new file mode 100644 index 000000000..b66b876f6 --- /dev/null +++ b/tests/unit/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.spec.constants.js @@ -0,0 +1,50 @@ +import { ResourcesTypesEnum as ResourceType } from '~/types' + +export const mockedEmptySectionsData = [] + +export const mockedNewEmptySectionsData = [ + { + title: '', + description: '', + resources: [] + } +] + +export const mockedCourseData = { + title: 'Course title', + description: 'Course description', + sections: [ + { + title: 'Course section1 title', + description: 'Course section1 description', + resources: [], + id: '17121748017182' + } + ] +} + +export const mockedSectionsData = [ + { + title: 'Section1', + description: 'Section1 description', + resources: [ + { + resource: { + _id: '66183816fb40f35f91bb77ce', + id: 'a1b2c3d4-5e6f-7a8b-9c0d-1e2f3b4c5d6e', + title: 'Lesson 1', + description: 'Lesson 1 description', + content: 'Lesson 1 content' + }, + resourceType: ResourceType.Lesson + } + ], + id: '17121748017180' + }, + { + title: 'Section2 title', + description: 'Section2 description', + resources: [], + id: '17121748017181' + } +] diff --git a/tests/unit/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.spec.jsx b/tests/unit/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.spec.jsx index fa2c64724..de8012cb2 100644 --- a/tests/unit/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.spec.jsx +++ b/tests/unit/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.spec.jsx @@ -1,7 +1,14 @@ import { screen, fireEvent, waitFor } from '@testing-library/react' -import CooperationActivitiesList from '~/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList' import { renderWithProviders } from '~tests/test-utils' -import { vi } from 'vitest' + +import { CourseResourceEventType, CourseSectionEventType } from '~/types' +import { + mockedCourseData, + mockedSectionsData, + mockedEmptySectionsData +} from '~tests/unit/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList.spec.constants' + +import CooperationActivitiesList from '~/containers/my-cooperations/cooperation-activities-list/CooperationActivitiesList' const originalDateNow = Date.now Date.now = () => 1487076708000 @@ -12,23 +19,7 @@ const TestsId = { closeIcon: 'CloseIcon' } -const mockedCourseData = { - title: 'Course title', - description: 'Course description', - sections: [ - { - title: 'Course section1 title', - description: 'Course section1 description', - lessons: [], - quizzes: [], - attachments: [], - id: '17121748017182' - } - ] -} - const renderWithMockData = ( - sectionIndex, sections = [], courseData = mockedCourseData, isAddedClicked = true, @@ -40,44 +31,57 @@ const renderWithMockData = ( selectedCourse: courseData, isAddedClicked: isAddedClicked, isNewActivity: isNewActivity, - currentSectionIndex: sectionIndex, sections: sections } } }) } -describe('CooperationActivitiesList with section data', () => { - const mockedSectionsData = [ - { - title: 'Section1', - description: 'Section1 description', - order: ['66183816fb40f35f91bb77ce'], - lessons: [ - { - _id: '66183816fb40f35f91bb77ce', - title: 'Lesson 1', - description: 'Lesson 1 description', - content: 'Lesson 1 content', - resourceType: 'lessons' - } - ], - quizzes: [], - attachments: [], - id: '17121748017180' - }, - { - title: 'Section2 title', - description: 'Section2 description', - lessons: [], - quizzes: [], - attachments: [], - id: '17121748017181' - } - ] +const mockDispatch = vi.fn() +vi.mock('~/hooks/use-redux', async () => { + const actual = await vi.importActual('~/hooks/use-redux') + return { + ...actual, + useAppDispatch: () => mockDispatch + } +}) +vi.mock('~/containers/course-sections-list/CourseSectionsList', async () => { + const CourseSectionsList = ( + await vi.importActual( + '~/containers/course-sections-list/CourseSectionsList' + ) + ).default + return { + __esModule: true, + default: (props) => ( + <> + + { + const parsed = JSON.parse(e.target.value) + if (props[parsed.event]) { + props[parsed.event](parsed.payload) + } + }} + /> + + ) + } +}) + +describe('CooperationActivitiesList with section data', () => { beforeEach(() => { - renderWithMockData(0, mockedSectionsData) + renderWithMockData(mockedSectionsData) }) afterEach(() => { @@ -85,8 +89,7 @@ describe('CooperationActivitiesList with section data', () => { }) it('should add a new section when Add activity button is clicked', async () => { - let sections = await screen.findAllByTestId(TestsId.activityContainer) - + const sections = await screen.findAllByTestId(TestsId.activityContainer) const [hoverElement] = sections fireEvent.mouseOver(hoverElement) @@ -98,22 +101,30 @@ describe('CooperationActivitiesList with section data', () => { ) fireEvent.click(menuItem) - sections = await screen.findAllByTestId(TestsId.activityContainer) - expect(sections.length).toBe(4) + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'cooperationsSlice/addNewCooperationSection', + payload: { + index: 0 + } + }) + }) }) it('should delete section resource', async () => { await waitFor(() => { - const deleteResourceBtn = screen.getByTestId( - TestsId.closeIcon - ).parentElement + const deleteResourceBtn = screen.getAllByTestId(TestsId.closeIcon)[0] fireEvent.click(deleteResourceBtn) }) - const resource = screen.queryByText('Lesson 1 description') - await waitFor(() => { - expect(resource).not.toBeInTheDocument() + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'cooperationsSlice/deleteResource', + payload: { + resourceId: mockedSectionsData[0].resources[0].resource.id, + sectionId: mockedSectionsData[0].id + } + }) }) }) @@ -123,19 +134,197 @@ describe('CooperationActivitiesList with section data', () => { ) const newTitle = 'New section title' - fireEvent.blur(titleInput, { - target: { value: newTitle } - }) + fireEvent.change(titleInput, { target: { value: newTitle } }) + fireEvent.blur(titleInput) await waitFor(() => { expect(titleInput.value).toBe(newTitle) }) }) + + it('should call sectionEventHandler with SectionAdded event', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'sectionEventHandler', + payload: { + type: CourseSectionEventType.SectionAdded, + index: 0 + } + }) + } + }) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'cooperationsSlice/addNewCooperationSection', + payload: { + index: 0 + } + }) + }) + }) + + it('should call sectionEventHandler with SectionRemoved event', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'sectionEventHandler', + payload: { + type: CourseSectionEventType.SectionRemoved, + sectionId: 'section-1' + } + }) + } + }) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'cooperationsSlice/deleteCooperationSection', + payload: 'section-1' + }) + }) + }) + + it('should call sectionEventHandler with SectionsOrderChange event', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'sectionEventHandler', + payload: { + type: CourseSectionEventType.SectionsOrderChange, + sections: [ + { id: 'section-2', name: 'Section 2' }, + { id: 'section-1', name: 'Section 1' } + ] + } + }) + } + }) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'cooperationsSlice/setCooperationSections', + payload: [ + { id: 'section-2', name: 'Section 2' }, + { id: 'section-1', name: 'Section 1' } + ] + }) + }) + }) + + it('should call resourceEventHandler with ResourceUpdated event', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'resourceEventHandler', + payload: { + type: CourseResourceEventType.ResourceUpdated, + sectionId: 'section-1', + resourceId: 'resource-1', + resource: { name: 'Updated Resource' } + } + }) + } + }) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'cooperationsSlice/updateResource', + payload: { + sectionId: 'section-1', + resourceId: 'resource-1', + resource: { name: 'Updated Resource' } + } + }) + }) + }) + + it('should call resourceEventHandler with ResourcesOrderChange event', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'resourceEventHandler', + payload: { + type: CourseResourceEventType.ResourcesOrderChange, + sectionId: 'section-1', + resources: [{ id: 'resource-1', name: 'Resource 1' }] + } + }) + } + }) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'cooperationsSlice/updateResourcesOrder', + payload: { + sectionId: 'section-1', + resources: [{ id: 'resource-1', name: 'Resource 1' }] + } + }) + }) + }) + + it('should call resourceEventHandler with AddSectionResources event', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'resourceEventHandler', + payload: { + type: CourseResourceEventType.AddSectionResources, + sectionId: 'section-1', + resources: [{ id: 'resource-1', name: 'Resource 1' }] + } + }) + } + }) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith({ + type: 'cooperationsSlice/addSectionResources', + payload: { + sectionId: 'section-1', + resources: [{ id: 'resource-1', name: 'Resource 1' }] + } + }) + }) + }) + + it('should call handleSectionInputChange event', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'handleSectionInputChange', + payload: { + id: 'id', + field: 'field', + value: 'value' + } + }) + } + }) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalled(1) + }) + }) }) describe('CooperationActivitiesList without section data', () => { - const mockedEmptySectionsData = [] - afterEach(() => { vi.clearAllMocks() }) @@ -145,24 +334,21 @@ describe('CooperationActivitiesList without section data', () => { }) it('should set only selected course sections in the data when no section was added', async () => { - renderWithMockData(0, mockedEmptySectionsData, mockedCourseData, true, true) + renderWithMockData(mockedEmptySectionsData, mockedCourseData, true, true) - const sections = await screen.findAllByTestId(TestsId.activityContainer) + const sections = await screen.findAllByTestId(TestsId.addButton) expect(sections.length).toBe(1) }) it('should add a new section when no section was added and no course was selected', async () => { renderWithMockData( - 0, mockedEmptySectionsData, { ...mockedCourseData, sections: [] }, false, true ) - const sections = await screen.findAllByTestId(TestsId.activityContainer) - await waitFor(() => { - expect(sections.length).toBe(1) - }) + const sections = await screen.findAllByTestId(TestsId.addButton) + expect(sections.length).toBe(1) }) }) diff --git a/tests/unit/containers/my-courses/course-toolbar/CourseToolbar.spec.jsx b/tests/unit/containers/my-courses/course-toolbar/CourseToolbar.spec.jsx index cb60e1be3..eb0eef52f 100644 --- a/tests/unit/containers/my-courses/course-toolbar/CourseToolbar.spec.jsx +++ b/tests/unit/containers/my-courses/course-toolbar/CourseToolbar.spec.jsx @@ -19,9 +19,7 @@ const mockData = { id: '1716926626910', title: '', description: '', - lessons: [], - quizzes: [], - attachments: [], + resources: [], order: [] } ] diff --git a/tests/unit/containers/tutor-profile/ProfileInfo.spec.jsx b/tests/unit/containers/tutor-profile/ProfileInfo.spec.jsx index cfde6c3fc..c340ed959 100644 --- a/tests/unit/containers/tutor-profile/ProfileInfo.spec.jsx +++ b/tests/unit/containers/tutor-profile/ProfileInfo.spec.jsx @@ -1,19 +1,22 @@ import { screen, fireEvent, waitFor } from '@testing-library/react' - import { renderWithProviders, TestSnackbar } from '~tests/test-utils' +import { useMatch } from 'react-router-dom' import useBreakpoints from '~/hooks/use-breakpoints' import ProfileInfo from '~/containers/user-profile/profile-info/ProfileInfo' -vi.mock('~/hooks/use-breakpoints') - const mockNavigate = vi.fn() -vi.mock('react-router-dom', async () => ({ - ...(await vi.importActual('react-router-dom')), - useMatch: () => false, - useNavigate: () => mockNavigate -})) +vi.mock('~/hooks/use-breakpoints') + +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useMatch: vi.fn(), + useNavigate: () => mockNavigate + } +}) Object.assign(window.navigator, { clipboard: { @@ -74,74 +77,96 @@ const userData = { updatedAt: '2023-07-12T19:33:43.616+00:00' } -function renderWithBreakpoints(data) { +function renderWithBreakpoints(data, role = 'student') { useBreakpoints.mockImplementation(() => data) - vi.mock('react-router-dom', async () => { - const actual = await vi.importActual('react-router-dom') - return { - ...actual, - useLocation: () => ({ - pathname: '/tutors/German/testUser' - }) - } - }) renderWithProviders( - + ) } -beforeEach(() => { - vi.clearAllMocks() -}) +describe('ProfileInfo component tests', () => { + beforeEach(() => { + vi.resetModules() + }) -describe('ProfileInfo test in my profile on laptop', () => { - beforeEach(() => renderWithBreakpoints(laptopData)) + describe('when profile is not own and on laptop', () => { + beforeEach(() => { + useMatch.mockImplementation(() => false) + renderWithBreakpoints(laptopData, 'student') + }) - it('should copy link to profile', () => { - const iconBtn = screen.getByTestId('icon-btn') + it('should copy link to profile', () => { + const iconBtn = screen.getByTestId('icon-btn') + fireEvent.click(iconBtn) - fireEvent.click(iconBtn) + expect(window.navigator.clipboard.writeText).toHaveBeenCalled() + }) - expect(window.navigator.clipboard.writeText).toHaveBeenCalled() - }) + it('should render send message button', () => { + const sendMessageBtn = screen.getByText( + /userProfilePage.profileInfo.sendMessage/i + ) - it('should render send message button', () => { - const sendMessageBtn = screen.getByText( - /userProfilePage.profileInfo.sendMessage/i - ) + expect(sendMessageBtn).toBeInTheDocument() + }) - expect(sendMessageBtn).toBeInTheDocument() - }) + it('should navigate on clicking "tutor offers" button', () => { + const tutorOffersBtn = screen.getByText( + /userProfilePage.profileInfo.tutorOffers/i + ) + fireEvent.click(tutorOffersBtn) - it('should click on `tutor offers` button', () => { - const tutorOffersBtn = screen.getByText( - /userProfilePage.profileInfo.tutorOffers/i - ) - fireEvent.click(tutorOffersBtn) - waitFor(() => { - expect(mockNavigate).toHaveBeenCalled() + waitFor(() => { + expect(mockNavigate).toHaveBeenCalled() + }) }) }) -}) -describe('ProfileInfo test in my profile on mobile', () => { - beforeEach(() => renderWithBreakpoints(mobileData)) + describe('when profile is not own and on mobile', () => { + beforeEach(() => { + useMatch.mockImplementation(() => false) + renderWithBreakpoints(mobileData, 'tutor') + }) + + it('should copy link to profile', () => { + const iconBtn = screen.getByTestId('icon-btn') + fireEvent.click(iconBtn) - it('should copy link to profile', () => { - const iconBtn = screen.getByTestId('icon-btn') + expect(window.navigator.clipboard.writeText).toHaveBeenCalled() + }) - fireEvent.click(iconBtn) + it('should render send message button', () => { + const sendMessageBtn = screen.getByText( + /userProfilePage.profileInfo.sendMessage/i + ) - expect(window.navigator.clipboard.writeText).toHaveBeenCalled() + expect(sendMessageBtn).toBeInTheDocument() + }) }) - it('should render send message button', () => { - const sendMessageBtn = screen.getByText( - /userProfilePage.profileInfo.sendMessage/i - ) + describe('test with different cases of useMatch', () => { + it('should render EditOutlinedIcon for own profile [ isMyProfile = true ]', () => { + useMatch.mockImplementation(() => true) + renderWithBreakpoints(mobileData, 'tutor') + + const editIcon = screen.getByTestId('icon-btn').querySelector('svg') + + expect(editIcon).toBeInTheDocument() + expect(editIcon.getAttribute('data-testid')).toBe('EditOutlinedIcon') + }) - expect(sendMessageBtn).toBeInTheDocument() + it('should render CopyRoundedIcon for not own profile [ isMyProfile = false ]', () => { + useMatch.mockImplementation(() => false) + renderWithBreakpoints(mobileData, 'tutor') + + const copyIcon = screen.getByTestId('icon-btn').querySelector('svg') + + expect(copyIcon).toBeInTheDocument() + expect(copyIcon.getAttribute('data-testid')).toBe( + 'ContentCopyRoundedIcon' + ) + }) }) }) diff --git a/tests/unit/pages/create-course/CreateCourse.spec.constants.js b/tests/unit/pages/create-course/CreateCourse.spec.constants.js new file mode 100644 index 000000000..a84edcbc5 --- /dev/null +++ b/tests/unit/pages/create-course/CreateCourse.spec.constants.js @@ -0,0 +1,213 @@ +import { ResourcesTypesEnum as ResourceType } from '~/types' + +export const mockCategoriesNames = [ + { _id: '64884f33fdc2d1a130c24ac2', name: 'Mathematic' }, + { _id: '660c27618a9fbf234b8bb4cd', name: 'Music' } +] + +export const mockSubjectsNames = [ + { _id: '6566133a2bccdd3e18dbe943', name: 'Algebra' }, + { _id: '010c23518a9fbf934b8bf4cd', name: 'Geometry' } +] + +export const mockNewSectionResource = { + _id: '66b67d84b58ba31be667ee9d', + id: 'e7f8a9b0-c1d2-3e4f-5a6b-7c8d9e0f1a2b', + author: '6658f73f93885febb491e08b', + title: 'New Lesson', + description: 'New lesson description', + content: '

Lesson New Plan:

', + attachments: [], + category: '64884f33fdc2d1a130c24ac2', + resourceType: ResourceType.Lesson +} + +export const mockUpdatedSectionResource = { + _id: '66b67d84b58ba31be667ee2d', + author: '6658f73f93885febb491e08b', + title: 'Updated Lesson', + description: 'Updated lesson description', + content: '

Lesson Updated Plan:

', + attachments: [], + category: '64884f33fdc2d1a130c24ac2', + resourceType: ResourceType.Lesson +} + +export const mockCourseResponseData = { + _id: '66b6862cb58ba31be667f1a6', + title: 'Mastering Systems of Linear Equations', + description: + 'This course is designed for 10th-grade students to explore and master the concepts and techniques involved in solving systems of linear equations.', + author: '6658f73f93885febb491e08b', + category: '64884f33fdc2d1a130c24ac2', + subject: '6566133a2bccdd3e18dbe943', + proficiencyLevel: ['Beginner', 'Intermediate'], + sections: [ + { + title: 'Introduction to Systems of Linear Equations', + description: + 'Understand what a system of linear equations is and the possible outcomes when solving them (one solution, no solution, or infinitely many solutions).', + resources: [ + { + resource: { + _id: '66b67d84b58ba31be667ee2d', + author: '6658f73f93885febb491e08b', + title: 'Exploring Systems of Linear Equations', + description: + 'Students will learn to solve systems of linear equations using different methods, including graphing, substitution, and elimination. ', + content: '

Lesson Plan:

', + attachments: [], + category: '6684175179e5232bce4579ed', + resourceType: ResourceType.Lesson, + availability: { + status: 'open', + date: null + } + }, + resourceType: ResourceType.Lesson + }, + { + resource: { + _id: '66b67e2ab58ba31be667ee4c', + title: 'Quiz: Exploring Systems of Linear Equations', + description: + 'Introduce the substitution method, where one equation is solved for one variable, and that expression is substituted into the other equation.', + items: ['66b67e02b58ba31be667ee43'], + author: '6658f73f93885febb491e08b', + category: '6684175179e5232bce4579ed', + resourceType: ResourceType.Quiz, + availability: { + status: 'open', + date: null + }, + settings: { + view: 'Scroll', + shuffle: false, + pointValues: false, + scoredResponses: false, + correctAnswers: false + } + }, + resourceType: ResourceType.Quiz + }, + { + resource: { + _id: '66b67eafb58ba31be667ee83', + author: '6658f73f93885febb491e08b', + fileName: 'Exploring Systems of Linear Equations.png', + link: '1723236050559-Exploring Systems of Linear Equations.png', + size: 39340, + category: '6684175179e5232bce4579ed', + resourceType: ResourceType.Attachment, + availability: { + status: 'open', + date: null + } + }, + resourceType: ResourceType.Attachment + } + ], + _id: '66b6862cb58ba31be667f1a7', + id: 'a1b2c3d4-5e6f-7a8b-9c0d-1e2f3b4c5d6e' + } + ] +} + +export const mockNewCourseData = { + _id: '66b6862cb58ba31be667f1a6', + title: 'Learning Multiplication Tables', + description: + 'This course is designed for elementary school students to learn and master the multiplication tables from 1 to 12.', + author: '6658f73f93885febb491e08b', + category: '64884f33fdc2d1a130c24ac2', + subject: '6566133a2bccdd3e18dbe943', + proficiencyLevel: ['Beginner'], + sections: [ + { + title: 'Introduction to Multiplication Tables', + description: + 'Understand the basics of multiplication and the importance of learning multiplication tables.', + resources: [ + { + resource: { + _id: '66b67d84b58ba31be667ee2d', + author: '6658f73f93885febb491e08b', + title: 'Basics of Multiplication', + description: + 'Students will learn the concept of multiplication and how it relates to addition.', + content: '

Lesson Plan:

', + attachments: [], + category: '6684175179e5232bce4579ed', + resourceType: ResourceType.Lesson, + availability: { + status: 'open', + date: null + } + }, + resourceType: ResourceType.Lesson + }, + { + resource: { + _id: '66b67e2ab58ba31be667ee4c', + title: 'Quiz: Basics of Multiplication', + description: + 'A quiz to test the understanding of basic multiplication concepts.', + items: ['66b67e02b58ba31be667ee43'], + author: '6658f73f93885febb491e08b', + category: '6684175179e5232bce4579ed', + resourceType: ResourceType.Quiz, + availability: { + status: 'open', + date: null + }, + settings: { + view: 'Scroll', + shuffle: false, + pointValues: false, + scoredResponses: false, + correctAnswers: false + } + }, + resourceType: ResourceType.Quiz + }, + { + resource: { + _id: '66b67eafb58ba31be667ee83', + author: '6658f73f93885febb491e08b', + fileName: 'Multiplication Tables Chart.png', + link: '1723236050559-Multiplication Tables Chart.png', + size: 39340, + category: '6684175179e5232bce4579ed', + resourceType: ResourceType.Attachment, + availability: { + status: 'open', + date: null + } + }, + resourceType: ResourceType.Attachment + } + ], + _id: '66b6862cb58ba31be667f1a7', + id: 'a1b2c3d4-5e6f-7a8b-9c0d-1e2f3b4c5d6e' + } + ] +} + +export const mockUpdatedCourseData = { + _id: '66b6862cb58ba31be667f1a6', + title: 'Mastering Systems of Linear Equations 2', + description: + 'This course is designed for 11th-grade students to explore and master the concepts and techniques involved in solving systems of linear equations.', + author: '6658f73f93885febb491e08b', + category: '64884f33fdc2d1a130c24ac2', + subject: '6566133a2bccdd3e18dbe943', + proficiencyLevel: ['Intermediate', 'Advanced'], + sections: [ + { + title: 'Introduction to Systems of Linear Equations', + description: + 'Understand what a system of linear equations is and the possible outcomes when solving them (one solution, no solution, or infinitely many solutions).', + resources: [] + } + ] +} diff --git a/tests/unit/pages/create-course/CreateCourse.spec.jsx b/tests/unit/pages/create-course/CreateCourse.spec.jsx index 34fbedd67..b1f29fd7c 100644 --- a/tests/unit/pages/create-course/CreateCourse.spec.jsx +++ b/tests/unit/pages/create-course/CreateCourse.spec.jsx @@ -1,145 +1,611 @@ -import { renderWithProviders } from '~tests/test-utils' import { screen, fireEvent, waitFor } from '@testing-library/react' +import { configureStore } from '@reduxjs/toolkit' + +import reducer from '~/redux/reducer' +import cooperationsReducer from '~/redux/features/cooperationsSlice' +import snackbarReducer, { openAlert } from '~/redux/features/snackbarSlice' + +import { renderWithProviders, mockAxiosClient } from '~tests/test-utils' +import { snackbarVariants } from '~/constants' +import { URLs } from '~/constants/request' +import { + UserRoleEnum, + CourseResourceEventType, + CourseSectionEventType +} from '~/types' +import { + mockCourseResponseData, + mockNewCourseData, + mockUpdatedCourseData, + mockNewSectionResource, + mockUpdatedSectionResource, + mockCategoriesNames, + mockSubjectsNames +} from '~tests/unit/pages/create-course/CreateCourse.spec.constants' import CreateCourse from '~/pages/create-course/CreateCourse' -import { expect } from 'vitest' -const mockedNavigate = vi.fn() -const mockHandleBlur = vi.fn() -const mockOnLevelChange = vi.fn() - -const categoriesNamesMock = [ - { _id: '660c27618a9fbf234b8bb4cf', name: 'Music' }, - { _id: '660c27618a9fbf234b8bb4cd', name: 'Sport' } -] +const mockState = { + appMain: { + userId: mockCourseResponseData.author, + userRole: UserRoleEnum.Tutor + } +} + +const store = configureStore({ + reducer: { + appMain: reducer, + snackbar: snackbarReducer, + cooperations: cooperationsReducer + }, + preloadedState: mockState +}) +const mockNavigate = vi.fn() +const mockUseParams = vi.fn() vi.mock('react-router-dom', async () => ({ ...(await vi.importActual('react-router-dom')), - useNavigate: () => mockedNavigate + useNavigate: () => mockNavigate, + useParams: () => mockUseParams() })) -describe('CreateCourse', () => { - beforeEach(async () => { - await waitFor(() => - renderWithProviders( - - ) - ) - }) - - it('should render cancel and save buttons', () => { - const cancelButton = screen.getByText('common.cancel') - expect(cancelButton).toBeInTheDocument() - - const saveButton = screen.getByText('common.save') - expect(saveButton).toBeInTheDocument() - }) +vi.mock('~/redux/features/snackbarSlice', async () => { + const actual = await vi.importActual('~/redux/features/snackbarSlice') + return { + ...actual, + openAlert: vi.fn() + } +}) - it('redirect by clicking cancel button', () => { - const cancelButton = screen.getByText('common.cancel') +const mockDispatch = vi.fn() +vi.mock('~/hooks/use-redux', async () => { + const actual = await vi.importActual('~/hooks/use-redux') + return { + ...actual, + useAppDispatch: () => mockDispatch + } +}) - fireEvent.click(cancelButton) +const mockFetchCourseData = vi.fn().mockResolvedValue(mockCourseResponseData) +vi.mock('~/hooks/use-axios', async () => { + const actual = await vi.importActual('~/hooks/use-axios') + return { + ...actual, + useAxios: vi.fn(() => ({ + loading: false, + response: null, + fetchData: mockFetchCourseData + })) + } +}) - expect(mockedNavigate).toHaveBeenCalled() +const mockHandleInputChange = vi.fn() +const mockHandleNonInputValueChange = vi.fn() +const mockHandleBlur = vi.fn() +const mockHandleSubmit = vi.fn().mockResolvedValue(mockUpdatedCourseData) +let mockInitialFormData = { + title: '', + description: '', + author: { _id: '' }, + category: null, + subject: null, + proficiencyLevel: [], + sections: [] +} +let mockOnSubmit +const updateFormData = (data) => { + mockInitialFormData = { ...mockInitialFormData, ...data } +} +const mockUseForm = vi.hoisted(() => { + return vi.fn(({ onSubmit } = {}) => { + mockOnSubmit = onSubmit + return { + handleSubmit: mockHandleSubmit, + handleInputChange: mockHandleInputChange, + handleNonInputValueChange: mockHandleNonInputValueChange, + handleBlur: mockHandleBlur, + data: mockInitialFormData, + errors: { + category: 'Please select a category', + subject: 'Please select a subject' + } + } }) +}) +vi.mock('~/hooks/use-form', async () => ({ + default: mockUseForm +})) - it('redirect by clicking save button', () => { - const saveButton = screen.getByText('common.save') - - fireEvent.click(saveButton) +vi.mock('~/containers/course-sections-list/CourseSectionsList', () => ({ + __esModule: true, + default: (props) => ( + { + const parsed = JSON.parse(e.target.value) + props[parsed.event](parsed.payload) + }} + /> + ) +})) - expect(mockedNavigate).toHaveBeenCalled() +describe('CreateCourse with params id', () => { + beforeEach(async () => { + mockUseParams.mockReset() + mockUseParams.mockReturnValue({ id: mockCourseResponseData._id }) + await waitFor(() => { + mockAxiosClient + .onGet(`${URLs.courses.get}/${mockCourseResponseData._id}`) + .reply(200, () => { + updateFormData(mockCourseResponseData) + return mockCourseResponseData + }) + mockAxiosClient + .onPost(URLs.courses.create, mockNewCourseData) + .reply(200, () => { + updateFormData(mockNewCourseData) + return mockNewCourseData + }) + mockAxiosClient + .onPatch( + `${URLs.courses.patch}/${mockCourseResponseData._id}`, + mockUpdatedCourseData + ) + .reply(200, () => { + updateFormData(mockUpdatedCourseData) + return mockUpdatedCourseData + }) + mockAxiosClient + .onDelete(`${URLs.courses.delete}/${mockCourseResponseData._id}`) + .reply(200, null) + mockAxiosClient + .onGet(URLs.categories.getNames) + .reply(200, mockCategoriesNames) + mockAxiosClient + .onGet(`${URLs.categories.get}/1${URLs.subjects.getNames}`) + .reply(200, mockSubjectsNames) + + renderWithProviders(, { store }) + }) }) - it('should render add section button', () => { - const addSectionButton = screen.getByText('course.addSectionBtn') + afterEach(() => { + vi.clearAllMocks() + mockUseParams.mockReset() + mockNavigate.mockReset() + mockDispatch.mockReset() + }) - expect(addSectionButton).toBeInTheDocument() + it('should render "Cancel", "Save" and "Add Section" buttons', async () => { + await waitFor(() => { + expect(screen.getByText('common.cancel')).toBeInTheDocument() + expect(screen.getByText('common.save')).toBeInTheDocument() + expect(screen.getByText('course.addSectionBtn')).toBeInTheDocument() + }) }) it('should choose the category from options list', async () => { const autocomplete = screen.getAllByRole('combobox')[0] expect(autocomplete).toBeInTheDocument() - expect(autocomplete.value).toBe('') fireEvent.click(autocomplete) fireEvent.focus(autocomplete) fireEvent.change(autocomplete, { - target: { value: categoriesNamesMock[1].name } + target: { value: mockCategoriesNames[1].name } }) fireEvent.keyDown(autocomplete, { key: 'ArrowDown' }) fireEvent.keyDown(autocomplete, { key: 'Enter' }) await waitFor(() => { - expect(autocomplete.value).toBe(categoriesNamesMock[1].name) + expect(autocomplete.value).toBe(mockCategoriesNames[1].name) }) fireEvent.keyDown(autocomplete, { key: 'ArrowDown' }) fireEvent.keyDown(autocomplete, { key: 'Enter' }) - expect(autocomplete.value).toBe(categoriesNamesMock[1].name) + expect(autocomplete.value).toBe(mockCategoriesNames[1].name) }) - it('should display error for category', () => { - const autocomplete = screen.getAllByRole('combobox')[0] + it('should choose the subject from options list', async () => { + const autocomplete = screen.getAllByRole('combobox')[1] + + expect(autocomplete).toBeInTheDocument() fireEvent.click(autocomplete) - fireEvent.blur(autocomplete) + fireEvent.focus(autocomplete) - const errorMessage = screen.getByText('common.errorMessages.category') + fireEvent.change(autocomplete, { + target: { value: mockSubjectsNames[1].name } + }) - expect(errorMessage).toBeInTheDocument() + fireEvent.keyDown(autocomplete, { key: 'ArrowDown' }) + fireEvent.keyDown(autocomplete, { key: 'Enter' }) + + await waitFor(() => { + expect(autocomplete.value).toBe(mockSubjectsNames[1].name) + }) }) - it('should display error for category', () => { - const autocomplete = screen.getAllByRole('combobox')[0] + it('should display error for category', async () => { + const categoryAutocomplete = screen.getAllByRole('combobox')[0] - fireEvent.click(autocomplete) - fireEvent.blur(autocomplete) + fireEvent.click(categoryAutocomplete) + fireEvent.blur(categoryAutocomplete) - const errorMessage = screen.getByText('common.errorMessages.category') + const saveButton = screen.getByText('common.save') + fireEvent.click(saveButton) - expect(errorMessage).toBeInTheDocument() + await waitFor(() => { + const errorMessage = screen.getByText('Please select a category') + expect(errorMessage).toBeInTheDocument() + }) }) it('should display error for subject', async () => { - const autocomplete = screen.getAllByRole('combobox')[0] + const subjectAutocomplete = screen.getAllByRole('combobox')[1] - expect(autocomplete).toBeInTheDocument() - expect(autocomplete.value).toBe('') + fireEvent.click(subjectAutocomplete) + fireEvent.blur(subjectAutocomplete) - fireEvent.click(autocomplete) - fireEvent.focus(autocomplete) + const errorMessage = screen.getByText('Please select a subject') - fireEvent.change(autocomplete, { - target: { value: categoriesNamesMock[1].name } + expect(errorMessage).toBeInTheDocument() + }) + + it('should render the proficiency levels in option list', async () => { + const select = screen.getByLabelText(/level/i) + expect(select).toBeInTheDocument() + + const proficiencyCheckbox = screen.getByDisplayValue( + /beginner,intermediate/i + ) + expect(proficiencyCheckbox).toBeInTheDocument() + }) + + it('should add a new section when the "Add Section" button is clicked', async () => { + const addSectionButton = screen.getByText('course.addSectionBtn') + fireEvent.click(addSectionButton) + + await waitFor(() => { + expect(mockHandleNonInputValueChange).toHaveBeenCalledWith( + 'sections', + expect.arrayContaining([ + expect.objectContaining({ + title: '', + description: '', + id: expect.any(String), + resources: [] + }) + ]) + ) }) + }) - fireEvent.keyDown(autocomplete, { key: 'ArrowDown' }) - fireEvent.keyDown(autocomplete, { key: 'Enter' }) + it('should call handleInputChange when "Course title" input is changed', async () => { + const inputField = screen.getByText(mockCourseResponseData.title) + expect(inputField).toBeInTheDocument() + + fireEvent.change(inputField, { target: { value: 'New course title' } }) + fireEvent.blur(inputField) await waitFor(() => { - expect(autocomplete.value).toBe(categoriesNamesMock[1].name) + expect(mockHandleInputChange).toHaveBeenCalledWith('title') }) + }) - fireEvent.keyDown(autocomplete, { key: 'ArrowDown' }) - fireEvent.keyDown(autocomplete, { key: 'Enter' }) + it('should call handleInputChange when "Course description" input is changed', async () => { + const inputField = screen.getByText(mockCourseResponseData.description) + expect(inputField).toBeInTheDocument() - expect(autocomplete.value).toBe(categoriesNamesMock[1].name) + fireEvent.change(inputField, { + target: { value: 'New course description' } + }) + fireEvent.blur(inputField) - const subjectAutocomplete = screen.getAllByRole('combobox')[1] + await waitFor(() => { + expect(mockHandleInputChange).toHaveBeenCalledWith('description') + }) + }) - fireEvent.click(subjectAutocomplete) - fireEvent.blur(subjectAutocomplete) + it('should update course with mockUpdatedCourseData and submit', async () => { + updateFormData(mockUpdatedCourseData) - const errorMessage = screen.getByText('common.errorMessages.subject') + const saveButton = screen.getByText('common.save') + fireEvent.click(saveButton) + await mockOnSubmit() - expect(errorMessage).toBeInTheDocument() + expect(mockAxiosClient.history.patch.length).toBe(1) + expect(mockAxiosClient.history.patch[0].url).toBe( + `${URLs.courses.patch}/${mockCourseResponseData._id}` + ) + await waitFor(() => { + const textAreas = screen.getAllByRole('textbox') + expect(textAreas[0].value).toBe(mockUpdatedCourseData.title) + expect(textAreas[1].value).toBe(mockUpdatedCourseData.description) + }) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + openAlert({ + severity: snackbarVariants.success, + message: 'myCoursesPage.newCourse.successEditedCourse' + }) + ) + }) + }) +}) + +describe('CreateCourse without params id', () => { + beforeEach(async () => { + mockUseParams.mockReset() + mockUseParams.mockReturnValue({ id: null }) + await waitFor(() => { + renderWithProviders(, { store }) + }) + }) + + afterEach(() => { + vi.clearAllMocks() + mockUseParams.mockReset() + mockNavigate.mockReset() + mockDispatch.mockReset() + }) + + it('should handle saving a new course when id is null', async () => { + updateFormData(mockNewCourseData) + + const saveButton = screen.getByText('common.save') + fireEvent.click(saveButton) + await mockOnSubmit() + + expect(mockAxiosClient.history.post.length).toBe(1) + expect(mockAxiosClient.history.post[0].url).toBe(URLs.courses.create) + await waitFor(() => { + const textAreas = screen.getAllByRole('textbox') + expect(textAreas[0].value).toBe(mockNewCourseData.title) + expect(textAreas[1].value).toBe(mockNewCourseData.description) + }) + + await waitFor(() => { + expect(mockDispatch).toHaveBeenCalledWith( + openAlert({ + severity: snackbarVariants.success, + message: 'myCoursesPage.newCourse.successCreatedCourse' + }) + ) + }) + }) +}) + +describe('Testing CreateCourse Event Handlers', () => { + const mockSectionId = mockNewCourseData.sections[0].id + beforeEach(async () => { + mockUseParams.mockReset() + mockUseParams.mockReturnValue({ id: mockNewCourseData._id }) + await waitFor(() => { + renderWithProviders(, { store }) + }) + }) + + afterEach(() => { + vi.clearAllMocks() + mockUseParams.mockReset() + mockNavigate.mockReset() + mockDispatch.mockReset() + }) + + it('should handle adding a new resource to a section [CourseResourceEventType.AddSectionResources] when isDuplicate=false', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'resourceEventHandler', + payload: { + type: CourseResourceEventType.AddSectionResources, + sectionId: mockSectionId, + resources: [mockNewSectionResource], + isDuplicate: false + } + }) + } + }) + + await waitFor(() => { + expect(mockHandleNonInputValueChange).toHaveBeenCalled(1) + expect(mockHandleNonInputValueChange).toHaveBeenCalledWith('sections', [ + { + _id: mockNewCourseData.sections[0]._id, + id: mockSectionId, + description: mockNewCourseData.sections[0].description, + resources: [ + ...mockNewCourseData.sections[0].resources, + { + resource: { ...mockNewSectionResource, id: expect.any(String) }, + resourceType: mockNewSectionResource.resourceType + } + ], + title: mockNewCourseData.sections[0].title + } + ]) + }) + }) + + it('should handle adding a new resource to a section [CourseResourceEventType.AddSectionResources] when isDuplicate=true', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'resourceEventHandler', + payload: { + type: CourseResourceEventType.AddSectionResources, + sectionId: mockSectionId, + resources: [mockNewSectionResource], + isDuplicate: true + } + }) + } + }) + + await waitFor(() => { + expect(mockHandleNonInputValueChange).toHaveBeenCalled(1) + expect(mockHandleNonInputValueChange).toHaveBeenCalledWith('sections', [ + expect.objectContaining({ + id: mockCourseResponseData.sections[0].id, + title: mockCourseResponseData.sections[0].title, + resources: expect.arrayContaining([ + expect.objectContaining({ + resource: expect.objectContaining({ + _id: mockCourseResponseData.sections[0].resources[0].resource + ._id, + title: + mockCourseResponseData.sections[0].resources[0].resource.title + }), + resourceType: + mockCourseResponseData.sections[0].resources[0].resourceType + }), + expect.objectContaining({ + resource: expect.objectContaining({ + _id: expect.any(String), + title: mockNewSectionResource.title, + isDuplicate: true + }), + resourceType: mockNewSectionResource.resourceType + }) + ]) + }) + ]) + }) + }) + + it('should handle resource update event [CourseResourceEventType.ResourceUpdated]', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'resourceEventHandler', + payload: { + type: CourseResourceEventType.ResourceUpdated, + sectionId: mockSectionId, + resourceId: mockUpdatedSectionResource.id, + resource: mockUpdatedSectionResource + } + }) + } + }) + + await waitFor(() => { + expect(mockHandleNonInputValueChange).toHaveBeenCalled(1) + }) + }) + + it('should handle resource order change even [CourseResourceEventType.ResourcesOrderChange]', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'resourceEventHandler', + payload: { + type: CourseResourceEventType.ResourcesOrderChange, + sectionId: mockSectionId, + resources: [mockUpdatedSectionResource] + } + }) + } + }) + + await waitFor(() => { + expect(mockHandleNonInputValueChange).toHaveBeenCalled(1) + }) + }) + + it('should handle resource removal event [CourseResourceEventType.ResourceRemoved]', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'resourceEventHandler', + payload: { + type: CourseResourceEventType.ResourceRemoved, + sectionId: mockSectionId, + resourceId: mockUpdatedSectionResource._id + } + }) + } + }) + + await waitFor(() => { + expect(mockHandleNonInputValueChange).toHaveBeenCalled(1) + }) + }) + + it('should handle section addition event [CourseSectionEventType.SectionAdded]', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'sectionEventHandler', + payload: { + type: CourseSectionEventType.SectionAdded + } + }) + } + }) + + await waitFor(() => { + expect(mockHandleNonInputValueChange).toHaveBeenCalled(1) + }) + }) + + it('should handle section order change event [CourseSectionEventType.SectionsOrderChange]', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'sectionEventHandler', + payload: { + type: CourseSectionEventType.SectionsOrderChange, + sections: [ + { id: 'section-1', title: 'Section 1' }, + { id: 'section-2', title: 'Section 2' }, + { id: 'section-3', title: 'Section 3' } + ] + } + }) + } + }) + + await waitFor(() => { + expect(mockHandleNonInputValueChange).toHaveBeenCalled(1) + }) + }) + + it('should handle section removal event [CourseSectionEventType.SectionRemoved]', async () => { + const courseSectionList = screen.getByTestId('mock-CourseSectionsList') + + fireEvent.change(courseSectionList, { + target: { + value: JSON.stringify({ + event: 'sectionEventHandler', + payload: { + type: CourseSectionEventType.SectionRemoved, + sectionId: mockSectionId + } + }) + } + }) + + await waitFor(() => { + expect(mockHandleNonInputValueChange).toHaveBeenCalled(1) + }) }) }) diff --git a/tests/unit/pages/create-or-edit-lesson/CreateOrEditLesson.spec.jsx b/tests/unit/pages/create-or-edit-lesson/CreateOrEditLesson.spec.jsx index 19d3fc50c..75978ad89 100644 --- a/tests/unit/pages/create-or-edit-lesson/CreateOrEditLesson.spec.jsx +++ b/tests/unit/pages/create-or-edit-lesson/CreateOrEditLesson.spec.jsx @@ -1,5 +1,6 @@ import { fireEvent, screen, waitFor } from '@testing-library/react' +import { ResourcesTypesEnum as ResourceType } from '~/types' import { mockAxiosClient, renderWithProviders, @@ -8,12 +9,13 @@ import { import { URLs } from '~/constants/request' import { createUrlPath } from '~/utils/helper-functions' import { authRoutes } from '~/router/constants/authRoutes' + import CreateOrEditLesson from '~/pages/create-or-edit-lesson/CreateOrEditLesson' const mockNavigate = vi.fn() const mockedCategory = { _id: 'categoryId', name: 'categoryName' } -const mockedAttachement = { +const mockedAttachment = { availability: { status: 'open', date: null @@ -24,14 +26,14 @@ const mockedAttachement = { link: '1722535882408-test.pdf', size: 15069, category: null, - resourceType: 'attachments', + resourceType: ResourceType.Attachment, createdAt: '2024-08-01T18:11:23.042Z', updatedAt: '2024-08-01T18:11:23.042Z' } -const mockedAttachementsResponse = { +const mockedAttachmentsResponse = { count: 1, - items: [mockedAttachement] + items: [mockedAttachment] } vi.mock('react-router-dom', async () => ({ @@ -43,7 +45,7 @@ describe('CreateOrEditLesson component test', () => { beforeAll(() => { mockAxiosClient .onGet(URLs.resources.attachments.get) - .reply(200, mockedAttachementsResponse) + .reply(200, mockedAttachmentsResponse) mockAxiosClient .onGet(URLs.resources.resourcesCategories.getNames) @@ -139,19 +141,19 @@ describe('CreateOrEditLesson component test', () => { it('should add an attachment', async () => { mockAxiosClient .onGet(URLs.resources.attachments.get) - .reply(200, mockedAttachementsResponse) + .reply(200, mockedAttachmentsResponse) const attachmentsBtn = screen.getByRole('button', { name: 'lesson.labels.attachments' }) fireEvent.click(attachmentsBtn) - const attachment = await screen.findByText(mockedAttachement.fileName) + const attachment = await screen.findByText(mockedAttachment.fileName) fireEvent.click(attachment) const addBtn = screen.getByText('common.add') fireEvent.click(addBtn) - const addedAttachment = screen.getByText(mockedAttachement.fileName) + const addedAttachment = screen.getByText(mockedAttachment.fileName) expect(addedAttachment).toBeInTheDocument() }) @@ -159,25 +161,25 @@ describe('CreateOrEditLesson component test', () => { it('should remove an attachment', async () => { mockAxiosClient .onGet(URLs.resources.attachments.get) - .reply(200, mockedAttachementsResponse) + .reply(200, mockedAttachmentsResponse) const attachmentsBtn = screen.getByRole('button', { name: 'lesson.labels.attachments' }) fireEvent.click(attachmentsBtn) - const attachment = await screen.findByText(mockedAttachement.fileName) + const attachment = await screen.findByText(mockedAttachment.fileName) fireEvent.click(attachment) const addBtn = screen.getByText('common.add') fireEvent.click(addBtn) - let addedAttachment = screen.getByText(mockedAttachement.fileName) + let addedAttachment = screen.getByText(mockedAttachment.fileName) expect(addedAttachment).toBeInTheDocument() const removeAttachmentBtn = screen.getByTestId('CloseIcon') fireEvent.click(removeAttachmentBtn) - addedAttachment = screen.queryByText(mockedAttachement.fileName) + addedAttachment = screen.queryByText(mockedAttachment.fileName) expect(addedAttachment).not.toBeInTheDocument() }) diff --git a/tests/unit/pages/quiz/Quiz.spec.jsx b/tests/unit/pages/quiz/Quiz.spec.jsx index 2b6aae1c0..68ff5ff99 100644 --- a/tests/unit/pages/quiz/Quiz.spec.jsx +++ b/tests/unit/pages/quiz/Quiz.spec.jsx @@ -1,7 +1,10 @@ import { screen, fireEvent, act } from '@testing-library/react' +import { renderWithProviders } from '~tests/test-utils' + import Quiz from '~/pages/quiz/Quiz' import useAxios from '~/hooks/use-axios' -import { renderWithProviders } from '~tests/test-utils' + +import { ResourcesTypesEnum as ResourceType } from '~/types' vi.mock('~/hooks/use-axios') @@ -19,7 +22,7 @@ const mockQuiz = { ], author: '660a8c7da2f78d2ed869b2bf', category: '665799d795ab9dbdd7ad40df', - resourceType: 'quizzes', + resourceType: ResourceType.Quiz, settings: { view: 'Stepper', shuffle: false, diff --git a/tests/unit/redux/cooperationsSlice.spec.js b/tests/unit/redux/cooperationsSlice.spec.js new file mode 100644 index 000000000..50dcb800f --- /dev/null +++ b/tests/unit/redux/cooperationsSlice.spec.js @@ -0,0 +1,278 @@ +import { isValidUUID } from '~/utils/validations/isValidUUID' + +import reducer, { + setCooperationSections, + addNewCooperationSection, + updateCooperationSection, + deleteCooperationSection, + addSectionResources, + updateResourcesOrder, + updateResource, + deleteResource, + setResourcesAvailability +} from '~/redux/features/cooperationsSlice' + +import { + ResourcesAvailabilityEnum, + ResourcesTypesEnum as ResourceType +} from '~/types' + +describe('Test cooperationsSlice', () => { + let initialState + + beforeEach(() => { + initialState = { + sections: [], + resourcesAvailability: ResourcesAvailabilityEnum.OpenAll + } + }) + + it('should set sections correctly with setCooperationSections', () => { + const sections = [ + { _id: '1', id: '1', resources: [] }, + { _id: '2', id: '2', resources: [] } + ] + const action = setCooperationSections(sections) + const state = reducer(initialState, action) + expect(state.sections).toEqual(sections) + }) + + it('should set sections and resources correctly with setCooperationSections (with _id)', () => { + const sections = [ + { + _id: '1', + resources: [ + { + _id: 'some id', + title: 'Resource 1', + resourceType: ResourceType.Attachment + } + ] + } + ] + const action = setCooperationSections(sections) + const state = reducer(initialState, action) + + const addedResource = state.sections[0].resources[0].resource + + expect(isValidUUID(addedResource.id)).toBe(true) + expect(state.sections).toMatchObject([ + { + _id: '1', + id: '1', + resources: [ + { + _id: 'some id', + resource: { + id: expect.any(String) + }, + resourceType: 'attachment', + title: 'Resource 1' + } + ] + } + ]) + }) + + it('should set sections and resources correctly with setCooperationSections (without _id)', () => { + const sections = [ + { + resources: [ + { + _id: 'some id', + title: 'Resource 1', + resourceType: ResourceType.Attachment + } + ] + } + ] + const action = setCooperationSections(sections) + const state = reducer(initialState, action) + + const addedResource = state.sections[0].resources[0].resource + + expect(isValidUUID(state.sections[0].id)).toBe(true) + expect(isValidUUID(addedResource.id)).toBe(true) + expect(state.sections).toMatchObject([ + { + id: expect.any(String), + resources: [ + { + _id: 'some id', + resource: { + id: expect.any(String) + }, + resourceType: 'attachment', + title: 'Resource 1' + } + ] + } + ]) + }) + + it('should update section correctly with updateCooperationSection', () => { + const sectionId = '1' + const initialStateWithSections = { + ...initialState, + sections: [{ id: sectionId, title: 'Initial Title', resources: [] }] + } + const action = updateCooperationSection({ + id: sectionId, + field: 'title', + value: 'Updated Title' + }) + const state = reducer(initialStateWithSections, action) + expect(state.sections[0].title).toEqual('Updated Title') + }) + + it('should delete section correctly with deleteCooperationSection', () => { + const sectionId = '1' + const initialStateWithSections = { + ...initialState, + sections: [{ id: sectionId, resources: [] }] + } + const action = deleteCooperationSection(sectionId) + const state = reducer(initialStateWithSections, action) + expect(state.sections.length).toBe(0) + }) + + it('should add new section correctly with addNewCooperationSection', () => { + const sectionIndex = 0 + const initialStateWithSections = { + ...initialState, + sections: [] + } + const action = addNewCooperationSection(sectionIndex) + + const state = reducer(initialStateWithSections, action) + expect(state.sections.length).toBe(1) + expect(isValidUUID(state.sections[0].id)).toBe(true) + }) + + it('should add resources to section correctly with addSectionResources when isDuplicate=false', () => { + const sectionId = '1' + const resources = [ + { _id: 'some id', title: 'Resource 1', resourceType: ResourceType.Quiz } + ] + const initialStateWithSections = { + ...initialState, + sections: [{ id: sectionId, resources: [] }] + } + const action = addSectionResources({ sectionId, resources }) + const state = reducer(initialStateWithSections, action) + + expect(state.sections[0].resources).toHaveLength(1) + + const addedResource = state.sections[0].resources[0].resource + + expect(isValidUUID(addedResource.id)).toBe(true) + expect(addedResource).toMatchObject({ + _id: 'some id', + title: 'Resource 1', + resourceType: ResourceType.Quiz + }) + }) + + it('should add resources to section correctly with addSectionResources when isDuplicate=true', () => { + const sectionId = '1' + const resources = [ + { + _id: 'some id', + title: 'Resource 1', + resourceType: ResourceType.Lesson + } + ] + const initialStateWithSections = { + ...initialState, + sections: [{ id: sectionId, resources: [] }] + } + const action = addSectionResources({ + sectionId, + resources, + isDuplicate: true + }) + const state = reducer(initialStateWithSections, action) + + expect(state.sections[0].resources).toHaveLength(1) + + const addedResource = state.sections[0].resources[0].resource + + expect(isValidUUID(addedResource.id)).toBe(true) + expect(addedResource).toMatchObject({ + _id: '', + title: 'Resource 1', + resourceType: ResourceType.Lesson, + isDuplicate: true + }) + }) + + it('should update resources order correctly with updateResourcesOrder', () => { + const sectionId = '1' + const initialStateWithSections = { + ...initialState, + sections: [{ id: sectionId, resources: [{ _id: 'r1' }, { _id: 'r2' }] }] + } + const newResources = [{ _id: 'r2' }, { _id: 'r1' }] + const action = updateResourcesOrder({ sectionId, resources: newResources }) + const state = reducer(initialStateWithSections, action) + expect(state.sections[0].resources[0].resource._id).toEqual('r2') + }) + + it('should update a resource correctly with updateResource', () => { + const sectionId = '1' + const resourceId = 'r1' + const initialStateWithSections = { + ...initialState, + sections: [ + { + id: sectionId, + resources: [{ resource: { id: resourceId, title: 'Old Title' } }] + } + ] + } + const action = updateResource({ + sectionId, + resourceId, + resource: { title: 'New Title' } + }) + const state = reducer(initialStateWithSections, action) + expect(state.sections[0].resources[0].resource.title).toEqual('New Title') + }) + + it('should delete a resource correctly with deleteResource', () => { + const sectionId = '1' + const resourceId = 'r1' + const initialStateWithSections = { + ...initialState, + sections: [ + { id: sectionId, resources: [{ resource: { id: resourceId } }] } + ] + } + const action = deleteResource({ sectionId, resourceId }) + const state = reducer(initialStateWithSections, action) + expect(state.sections[0].resources.length).toBe(0) + }) + + it('should set resources availability correctly with setResourcesAvailability', () => { + const sectionId = '1' + const initialStateWithSections = { + ...initialState, + sections: [ + { + id: sectionId, + resources: [ + { resource: { _id: 'r1', availability: { status: 'Open' } } } + ] + } + ] + } + const action = setResourcesAvailability(ResourcesAvailabilityEnum.ClosedAll) + const state = reducer(initialStateWithSections, action) + expect(state.resourcesAvailability).toBe( + ResourcesAvailabilityEnum.ClosedAll + ) + expect(state.sections[0].resources[0].resource.availability.status).toBe( + 'closed' + ) + }) +}) diff --git a/tests/unit/utils/validations/isValidUUID.spec.js b/tests/unit/utils/validations/isValidUUID.spec.js new file mode 100644 index 000000000..140317cca --- /dev/null +++ b/tests/unit/utils/validations/isValidUUID.spec.js @@ -0,0 +1,27 @@ +import { isValidUUID } from '~/utils/validations/isValidUUID' + +describe('isValidUUID', () => { + test('returns true for valid UUID v4', () => { + expect(isValidUUID('3b17ccb0-ea8b-4685-b576-0b5de7b3f0ef')).toBe(true) + expect(isValidUUID('550e8400-e29b-41d4-a716-446655440000')).toBe(true) + }) + + test('returns false for invalid UUIDs', () => { + expect(isValidUUID('123e4567-e89b-12d3-a456-42661417400')).toBe(false) + expect(isValidUUID('123e4567-e89b-12d3-a456-4266141740000')).toBe(false) + expect(isValidUUID('123e4567-e89b-12d3-a456-42661417400z')).toBe(false) + expect(isValidUUID('123e4567-e89b-12d3-a456-42661417400G')).toBe(false) + expect(isValidUUID('123e4567-e89b-12d3-a456-42661417400-')).toBe(false) + expect(isValidUUID('123e4567-e89b-12d3-a456-42661417400')).toBe(false) + expect(isValidUUID('550e8400-e29b-41d4-a716-44665544000')).toBe(false) + expect(isValidUUID('550e8400-e29b-41d4-a716-4466554400000')).toBe(false) + }) + + test('returns false for non-UUID strings', () => { + expect(isValidUUID('')).toBe(false) + expect(isValidUUID(null)).toBe(false) + expect(isValidUUID(undefined)).toBe(false) + expect(isValidUUID('not-a-uuid')).toBe(false) + expect(isValidUUID('12345678-1234-1234-1234-123456789012')).toBe(false) + }) +})