refactor: removed edit course page store

This commit is contained in:
Sergey Yarkov 2021-12-14 01:00:05 +03:00
parent 5d70c629ea
commit 21779419c9
10 changed files with 101 additions and 180 deletions

View File

@ -19,15 +19,17 @@ type DeleteLessonDialogPropsType = {
lesson: Pick<ILesson, 'id' | 'title'>; lesson: Pick<ILesson, 'id' | 'title'>;
isOpen: boolean; isOpen: boolean;
onClose: () => void; onClose: () => void;
onConfirmed?: (id: string) => void | undefined;
}; };
const DeleteLessonDialog: React.FC<DeleteLessonDialogPropsType> = ({ lesson, isOpen, onClose }) => { const DeleteLessonDialog: React.FC<DeleteLessonDialogPropsType> = ({ lesson, isOpen, onConfirmed, onClose }) => {
const { deleteLesson, isLoading } = useDeleteLesson(); const { deleteLesson, isLoading } = useDeleteLesson();
const handleDelete = async (isConfirmed: boolean) => { const handleDelete = async (isConfirmed: boolean) => {
if (isConfirmed) { if (isConfirmed) {
try { try {
await deleteLesson(lesson.id); await deleteLesson(lesson.id);
onConfirmed && onConfirmed(lesson.id);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }

View File

@ -22,14 +22,14 @@ import { BeatLoader } from 'react-spinners';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { FetchUsersParamsType } from '@educt/types'; import { FetchUsersParamsType } from '@educt/types';
type SelectUsersInputPropsType = { type SelectStudentsInputPropsType = {
placeholder?: string | undefined; placeholder?: string | undefined;
searchParams?: FetchUsersParamsType | undefined; searchParams?: FetchUsersParamsType | undefined;
onSelect: (selected: Array<IUser>) => void; onSelect: (selected: Array<IUser>) => void;
currentStudents: ICourse['students']; currentStudents: ICourse['students'];
}; };
const SelectUsersInput: React.FC<SelectUsersInputPropsType> = ({ const SelectStudentsInput: React.FC<SelectStudentsInputPropsType> = ({
placeholder, placeholder,
searchParams: params, searchParams: params,
onSelect, onSelect,
@ -151,4 +151,4 @@ const SelectUsersInput: React.FC<SelectUsersInputPropsType> = ({
); );
}; };
export default SelectUsersInput; export default SelectStudentsInput;

View File

@ -2,24 +2,20 @@
* Types * Types
*/ */
import { ICourse } from '@educt/interfaces'; import { ICourse } from '@educt/interfaces';
import { CourseServiceInstance } from '@educt/services';
/** /**
* Hooks * Hooks
*/ */
import { useEffect } from 'react'; import { useEffect } from 'react';
import useAsync from '../../useAsync'; import useAsync from '../../useAsync';
import { useRootStore } from '../../useRootStore';
type CourseResultDataType = Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'>; type CourseResultDataType = Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'>;
const useFetchCourse = (id: string) => { const useFetchCourse = (id: string) => {
const {
pageStore: { editCourseStore },
} = useRootStore();
const fetch = async (id: string) => { const fetch = async (id: string) => {
const result = await editCourseStore.loadCourseById(id); const { data } = await CourseServiceInstance.fetchById(id);
return result.data; return data;
}; };
const { execute: fetchCourseById, ...state } = useAsync<CourseResultDataType, Parameters<typeof fetch>>(fetch); const { execute: fetchCourseById, ...state } = useAsync<CourseResultDataType, Parameters<typeof fetch>>(fetch);

View File

@ -1,22 +1,19 @@
import { useToast } from '@chakra-ui/toast'; import { useToast } from '@chakra-ui/toast';
import { ILesson } from '@educt/interfaces'; import { ILesson } from '@educt/interfaces';
import { LessonServiceInstance } from '@educt/services';
import { useErrorHandler } from 'react-error-boundary'; import { useErrorHandler } from 'react-error-boundary';
import useAsync from '../../useAsync'; import useAsync from '../../useAsync';
import { useRootStore } from '../../useRootStore';
type CreatedLessonDataType = ILesson; type CreatedLessonDataType = ILesson;
const useDeleteLesson = () => { const useDeleteLesson = () => {
const {
pageStore: { editCourseStore },
} = useRootStore();
const toast = useToast(); const toast = useToast();
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const deleteLesson = async (id: string) => { const deleteLesson = async (id: string) => {
try { try {
const result = await editCourseStore.deleteLessonById(id); const result = await LessonServiceInstance.deleteLesson(id);
toast({ title: 'Lesson deleted', status: 'info' }); toast({ title: 'Lesson deleted.', status: 'info' });
return result.data; return result.data;
} catch (error: any) { } catch (error: any) {
if (error.response) { if (error.response) {

View File

@ -1,10 +1,11 @@
import React, { useState } from 'react'; import React from 'react';
import { observer } from 'mobx-react';
import { ItemProps, Virtuoso } from 'react-virtuoso'; import { ItemProps, Virtuoso } from 'react-virtuoso';
import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from 'react-beautiful-dnd';
import moment from 'moment'; import moment from 'moment';
import * as helpers from '@educt/helpers';
import { DragDropContext, Draggable, DraggableProvided, Droppable, DropResult } from 'react-beautiful-dnd';
import { Flex, Box, Text, IconButton, Icon, Button } from '@chakra-ui/react'; import { Flex, Box, Text, IconButton, Icon, Button } from '@chakra-ui/react';
import { DeleteIcon, DragHandleIcon } from '@chakra-ui/icons'; import { DeleteIcon, DragHandleIcon } from '@chakra-ui/icons';
import { MdTimer, MdAttachment } from 'react-icons/md';
/** /**
* Types * Types
@ -19,12 +20,11 @@ import DeleteLessonDialog from '@educt/components/Dialogs/DeleteLessonDialog';
/** /**
* Hooks * Hooks
*/ */
import { useState } from 'react';
import { useHistory } from 'react-router'; import { useHistory } from 'react-router';
import { useColorMode } from '@chakra-ui/react'; import { useColorMode } from '@chakra-ui/react';
import { MdTimer, MdAttachment } from 'react-icons/md';
import { useRootStore } from '@educt/hooks/useRootStore';
import useDidMountEffect from '@educt/hooks/useDidMountEffect';
import { useDisclosure } from '@chakra-ui/hooks'; import { useDisclosure } from '@chakra-ui/hooks';
import useDidMountEffect from '@educt/hooks/useDidMountEffect';
/** /**
* Contexts * Contexts
@ -55,19 +55,16 @@ const CreateLessonButton: React.FC<{ id: string }> = ({ id }) => {
const LessonList: React.FC<LessonListPropsType> = ({ course }) => { const LessonList: React.FC<LessonListPropsType> = ({ course }) => {
const history = useHistory(); const history = useHistory();
const { const { onOpen: onOpenDelDialog, onClose: onCloseDelDialog, isOpen: isOpenDelDialog } = useDisclosure();
pageStore: { editCourseStore }, const [lessons, setLessons] = useState<Array<ILesson>>(course.lessons);
} = useRootStore();
const { onOpen: onOpenDeleteDialog, onClose: onCloseDeleteDialog, isOpen: isOpenDeleteDialog } = useDisclosure();
const [deleting, setDeleting] = useState<Pick<ILesson, 'id' | 'title'> | null>(null); const [deleting, setDeleting] = useState<Pick<ILesson, 'id' | 'title'> | null>(null);
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const lessons = course.lessons;
const handleCreateLesson = (): void => history.push('/lessons/create'); const handleCreateLesson = (): void => history.push('/lessons/create');
const handleEditLesson = (id: string): void => history.push(`/lessons/edit/${id}`); const handleEditLesson = (id: string): void => history.push(`/lessons/edit/${id}`);
const handleDeleteLesson = (lesson: ILesson): void => { const handleDeleteLesson = (lesson: ILesson): void => {
setDeleting({ id: lesson.id, title: lesson.title }); setDeleting({ id: lesson.id, title: lesson.title });
onOpenDeleteDialog(); onOpenDelDialog();
}; };
const handleChangeOrder = async (ids: string[]) => { const handleChangeOrder = async (ids: string[]) => {
@ -84,9 +81,11 @@ const LessonList: React.FC<LessonListPropsType> = ({ course }) => {
return; return;
} }
editCourseStore.reorderLessons(result.source.index, result.destination.index); setLessons(helpers.arrayMove(lessons, result.source.index, result.destination.index));
}; };
const onDeleted = (id: string) => setLessons(prev => prev.filter(l => l.id !== id));
const LessonItem = React.useMemo(() => { const LessonItem = React.useMemo(() => {
return ({ return ({
provided, provided,
@ -199,7 +198,12 @@ const LessonList: React.FC<LessonListPropsType> = ({ course }) => {
{lessons.length !== 0 ? ( {lessons.length !== 0 ? (
<> <>
{deleting && ( {deleting && (
<DeleteLessonDialog lesson={deleting} isOpen={isOpenDeleteDialog} onClose={onCloseDeleteDialog} /> <DeleteLessonDialog
lesson={deleting}
isOpen={isOpenDelDialog}
onClose={onCloseDelDialog}
onConfirmed={onDeleted}
/>
)} )}
<Flex mt='2' mb='3' padding='0 20px' alignItems='center' justifyContent='space-between'> <Flex mt='2' mb='3' padding='0 20px' alignItems='center' justifyContent='space-between'>
<Text fontSize='sm' color='gray.500'> <Text fontSize='sm' color='gray.500'>
@ -255,4 +259,4 @@ const LessonList: React.FC<LessonListPropsType> = ({ course }) => {
); );
}; };
export default observer(LessonList); export default LessonList;

View File

@ -1,17 +1,16 @@
import React from 'react'; import React from 'react';
import { Table, Tbody, Button, Input, InputGroup, InputLeftElement, Flex, Box, Text } from '@chakra-ui/react'; import { Table, Tbody, Input, InputGroup, InputLeftElement, Flex, Box, Text } from '@chakra-ui/react';
import { MdSearch } from 'react-icons/md'; import { MdSearch } from 'react-icons/md';
import AddStudentsModal from '@educt/components/Modals/AddStudentsModal'; import AddStudentsModal from '@educt/components/Modals/AddStudentsModal';
import { AddButton } from '@educt/components/Buttons'; import { AddButton } from '@educt/components/Buttons';
import BulkActionsMenu from './BulkActionsMenu'; import BulkActionsMenu from './BulkActionsMenu';
import StudentTableHead from './StudentTableHead'; import StudentTableHead from './StudentTableHead';
import { AddIcon } from '@chakra-ui/icons';
/** /**
* Types * Types
*/ */
import type { StudentTableRowPropsType } from './StudentTableRow'; import type { StudentTableRowPropsType } from './StudentTableRow';
import type { ICourse } from '@educt/interfaces'; import type { ICourse, IUser } from '@educt/interfaces';
/** /**
* Hooks * Hooks
@ -28,9 +27,8 @@ type StudentTableListPropsType = {
const StudentTableList: React.FC<StudentTableListPropsType> = props => { const StudentTableList: React.FC<StudentTableListPropsType> = props => {
const { render: Row, course } = props; const { render: Row, course } = props;
const { students } = course;
const [rows, setRows] = useState<ICourse['students']>(students); const [rows, setRows] = useState<ICourse['students']>(props.course.students);
const [selected, setSelected] = useState<ICourse['students']>([]); const [selected, setSelected] = useState<ICourse['students']>([]);
const [search, setSearch] = useState<string>(''); const [search, setSearch] = useState<string>('');
const { const {
@ -50,20 +48,21 @@ const StudentTableList: React.FC<StudentTableListPropsType> = props => {
*/ */
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => { const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value); setSearch(e.target.value);
setRows([ setRows(
...students.filter( props.course.students.filter(
s => s.fullname.toLowerCase().includes(e.target.value) || s.email.toLowerCase().includes(e.target.value) s => s.fullname.toLowerCase().includes(e.target.value) || s.email.toLowerCase().includes(e.target.value)
), )
]); );
}; };
/**
* Delete student from state after removing
*/
const onRemoved = (removedStudents: ICourse['students']) => { const onRemoved = (removedStudents: ICourse['students']) => {
setSelected(prev => prev.filter(s => removedStudents.every(r => s.id !== r.id))); setSelected(prev => prev.filter(s => removedStudents.every(r => s.id !== r.id)));
}; };
const onAdded = (users: IUser[]) => {
setRows(prev => [...prev, ...users]);
};
/** /**
* Select all students in a table * Select all students in a table
*/ */
@ -133,7 +132,7 @@ const StudentTableList: React.FC<StudentTableListPropsType> = props => {
onClose={onCloseAddStudentModal} onClose={onCloseAddStudentModal}
course={course} course={course}
currentStudents={rows} currentStudents={rows}
onAdded={users => setRows(prev => [...prev, ...users])} onAdded={onAdded}
/> />
<Flex justifyContent='space-between' flexDir={{ base: 'column', lg: 'row' }}> <Flex justifyContent='space-between' flexDir={{ base: 'column', lg: 'row' }}>
<Flex mb='2' alignItems='center'> <Flex mb='2' alignItems='center'>
@ -164,7 +163,7 @@ const StudentTableList: React.FC<StudentTableListPropsType> = props => {
<AddButton onClick={onOpenAddStudentModal} /> <AddButton onClick={onOpenAddStudentModal} />
</Flex> </Flex>
</Flex> </Flex>
<Table overflow='hidden' borderRadius='lg' mt='2'> <Table borderRadius='lg' mt='2'>
<StudentTableHead onSelectAll={handleSelectAll} /> <StudentTableHead onSelectAll={handleSelectAll} />
<Tbody> <Tbody>
{rows.map(student => ( {rows.map(student => (
@ -184,7 +183,9 @@ const StudentTableList: React.FC<StudentTableListPropsType> = props => {
</Table> </Table>
{isEmptyRows && ( {isEmptyRows && (
<Box textAlign='center' m='6'> <Box textAlign='center' m='6'>
<Text color='gray.500'>Cannot find any user.</Text> <Text color='gray.500' userSelect='none'>
Cannot find any user.
</Text>
</Box> </Box>
)} )}
<Text mt='4' fontSize='sm' color='gray.500'> <Text mt='4' fontSize='sm' color='gray.500'>

View File

@ -21,7 +21,6 @@ import LessonList from './components/LessonList';
* Hooks * Hooks
*/ */
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { useRootStore } from '@educt/hooks/useRootStore';
import { useFetchCourse } from '@educt/hooks/queries'; import { useFetchCourse } from '@educt/hooks/queries';
import { useDisclosure } from '@chakra-ui/hooks'; import { useDisclosure } from '@chakra-ui/hooks';
import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements'; import { PageContent, PageHeading, PageWrapper } from '@educt/components/PageElements';
@ -33,12 +32,7 @@ import { StatusButton } from './components';
*/ */
const EditCoursePage: React.FC<IPageProps> = () => { const EditCoursePage: React.FC<IPageProps> = () => {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const { const { error, data: course, isLoading } = useFetchCourse(params.id);
pageStore: {
editCourseStore: { course },
},
} = useRootStore();
const { error, isLoading } = useFetchCourse(params.id);
const { onOpen: onOpenDeleteDialog, onClose: onCloseDeleteDialog, isOpen: isOpenDeleteDialog } = useDisclosure(); const { onOpen: onOpenDeleteDialog, onClose: onCloseDeleteDialog, isOpen: isOpenDeleteDialog } = useDisclosure();
/** /**
@ -68,13 +62,12 @@ const EditCoursePage: React.FC<IPageProps> = () => {
</PageHeading> </PageHeading>
<PageContent> <PageContent>
<Box> <Box>
<Tabs isLazy mt='8'> <Tabs mt='8'>
<TabList> <TabList>
<Tab>Info</Tab> <Tab>Info</Tab>
<Tab>Lessons</Tab> <Tab>Lessons</Tab>
<Tab>Students</Tab> <Tab>Students</Tab>
</TabList> </TabList>
<TabPanels> <TabPanels>
<TabPanel> <TabPanel>
<Box> <Box>

View File

@ -76,63 +76,61 @@ const UserList: React.FC<UserListPropsType> = ({ render: Item, limit }) => {
<Box> <Box>
{editing && <EditUserForm user={editing} isOpen={isOpenEditModal} onClose={onCloseEditModal} />} {editing && <EditUserForm user={editing} isOpen={isOpenEditModal} onClose={onCloseEditModal} />}
{deleting && <DeleteUserDialog user={deleting} isOpen={isOpenDeleteDialog} onClose={onCloseDeleteDialog} />} {deleting && <DeleteUserDialog user={deleting} isOpen={isOpenDeleteDialog} onClose={onCloseDeleteDialog} />}
{users.length !== 0 ? (
<> <Flex justifyContent='space-between' sx={{ gap: '10px' }} flexWrap='wrap'>
<Flex justifyContent='space-between' sx={{ gap: '10px' }} flexWrap='wrap'> <UserSearch />
<UserSearch /> <CreateUserForm />
<CreateUserForm /> </Flex>
</Flex> <Box>
<Box> <Flex mt='5' p='0 10px' fontWeight='bold' alignItems='center' justifyContent='space-between'>
<Flex mt='5' p='0 10px' fontWeight='bold' alignItems='center' justifyContent='space-between'> <Text>Total: ({pagination.total})</Text>
<Text>Total: ({pagination.total})</Text> <Text>Actions</Text>
<Text>Actions</Text> </Flex>
</Flex> {!isLoading ? (
{!isLoading ? ( <Stack mt='4' spacing='2'>
<Stack mt='4' spacing='2'> {users.map(user => (
{users.map(user => ( <Item
<Item key={user.id}
key={user.id} user={user}
user={user} onEdit={user => handleEditUser(user)}
onEdit={user => handleEditUser(user)} onDelete={user => handleDeleteUser({ id: user.id, fullname: user.fullname })}
onDelete={user => handleDeleteUser({ id: user.id, fullname: user.fullname })} />
/> ))}
))} </Stack>
</Stack> ) : (
) : ( <LoadingList />
<LoadingList /> )}
)} </Box>
</Box> {users.length === 0 && (
<Flex margin='2rem 0' flexDirection={{ base: 'column', sm: 'column', md: 'row' }}> <Box textAlign='center' userSelect='none'>
<Box textAlign={{ base: 'center', sm: 'center', md: 'left' }} mb={{ base: '2', md: '0' }}>
<Text fontSize='sm'>
Page <b>{pagination.current_page}</b> of {pagesCount}
</Text>
</Box>
<Flex ml='auto' mr='auto' justifyContent='center' flexWrap='wrap' alignItems='center' sx={{ gap: '30px' }}>
<Button
onClick={() => setSearchingPage(prevP => prevP - 1)}
disabled={pagination.current_page <= 1}
variant='link'
leftIcon={<ChevronLeftIcon />}
>
Previous page
</Button>
<Button
onClick={() => setSearchingPage(prevP => prevP + 1)}
disabled={pagination.current_page === pagesCount}
variant='link'
rightIcon={<ChevronRightIcon />}
>
Next page
</Button>
</Flex>
</Flex>
</>
) : (
<Box textAlign='center' mt='10' userSelect='none'>
<Text color='gray.500'>Cannot find any users.</Text> <Text color='gray.500'>Cannot find any users.</Text>
</Box> </Box>
)} )}
<Flex margin='2rem 0' flexDirection={{ base: 'column', sm: 'column', md: 'row' }}>
<Box textAlign={{ base: 'center', sm: 'center', md: 'left' }} mb={{ base: '2', md: '0' }}>
<Text fontSize='sm'>
Page <b>{pagination.current_page}</b> of {pagesCount}
</Text>
</Box>
<Flex ml='auto' mr='auto' justifyContent='center' flexWrap='wrap' alignItems='center' sx={{ gap: '30px' }}>
<Button
onClick={() => setSearchingPage(prevP => prevP - 1)}
disabled={pagination.current_page <= 1}
variant='link'
leftIcon={<ChevronLeftIcon />}
>
Previous page
</Button>
<Button
onClick={() => setSearchingPage(prevP => prevP + 1)}
disabled={pagination.current_page === pagesCount}
variant='link'
rightIcon={<ChevronRightIcon />}
>
Next page
</Button>
</Flex>
</Flex>
</Box> </Box>
); );
}; };

View File

@ -1,6 +1,5 @@
import { makeAutoObservable } from 'mobx'; import { makeAutoObservable } from 'mobx';
import CoursesStore from './pages/CoursesStore'; import CoursesStore from './pages/CoursesStore';
import EditCourseStore from './pages/EditCourseStore';
import RootStore from './RootStore'; import RootStore from './RootStore';
export default class PageStore { export default class PageStore {
@ -8,8 +7,6 @@ export default class PageStore {
public coursesStore: CoursesStore; public coursesStore: CoursesStore;
public editCourseStore: EditCourseStore;
constructor(root: RootStore) { constructor(root: RootStore) {
this.root = root; this.root = root;
@ -17,7 +14,6 @@ export default class PageStore {
* Pages * Pages
*/ */
this.coursesStore = new CoursesStore(this); this.coursesStore = new CoursesStore(this);
this.editCourseStore = new EditCourseStore(this);
makeAutoObservable(this); makeAutoObservable(this);
} }

View File

@ -1,66 +0,0 @@
import { makeAutoObservable, runInAction } from 'mobx';
import * as helpers from '@educt/helpers';
import { ICourse } from '@educt/interfaces';
import PageStore from '../PageStore';
import { CourseServiceInstance, LessonServiceInstance } from '@educt/services';
export default class EditCourseStore {
public pageStore: PageStore;
public course: Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'> | null = null;
constructor(pageStore: PageStore) {
this.pageStore = pageStore;
makeAutoObservable(this);
}
/**
* Load course by id into store
*
* @param id Course id
* @returns Course response
*/
public async loadCourseById(id: string) {
const result = await CourseServiceInstance.fetchById(id);
runInAction(() => {
this.course = result.data;
});
return result;
}
// TODO update stor on detaching, attaching, deleting students
/**
* Remove lesson from store
*
* @param id Lesson id
* @returns Deleted lesson response
*/
public async deleteLessonById(id: string) {
const result = await LessonServiceInstance.deleteLesson(id);
runInAction(() => {
if (this.course) {
this.course.lessons = this.course.lessons.filter(lesson => lesson.id !== result.data.id);
}
});
return result;
}
/**
* Reorder lessons in store list on drag
*
* @param oldIndex
* @param newIndex
*/
public reorderLessons(oldIndex: number, newIndex: number): void {
if (this.course) {
this.course.lessons = helpers.arrayMove(this.course.lessons, oldIndex, newIndex);
}
}
}