feat: attach student list query

This commit is contained in:
Sergey Yarkov 2021-12-10 23:04:58 +03:00
parent 7303dd5b43
commit 5c0599fe0a
8 changed files with 219 additions and 129 deletions

View File

@ -17,14 +17,18 @@ import { IconType } from 'react-icons';
export interface IBaseModalProps extends ModalProps {
isOpen: boolean;
onClose: () => void;
onProceed: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
heading: string;
icon?: IconType;
description?: string;
confirmText?: string;
isLoading?: boolean | undefined;
loadingText?: string | undefined;
}
const BaseModal: React.FC<IBaseModalProps> = props => {
const { heading, description, isOpen, onClose, confirmText, icon, ...modalProps } = props;
const { heading, description, isOpen, onClose, onProceed, confirmText, icon, isLoading, loadingText, ...modalProps } =
props;
return (
<Modal isOpen={isOpen} onClose={onClose} {...modalProps}>
@ -54,7 +58,14 @@ const BaseModal: React.FC<IBaseModalProps> = props => {
<Button onClick={onClose} borderRadius='md' mr='2'>
Close
</Button>
<Button onClick={onClose} borderRadius='md' variant='outline' colorScheme='blue'>
<Button
onClick={onProceed}
isLoading={isLoading}
loadingText={loadingText || 'Loading...'}
borderRadius='md'
variant='outline'
colorScheme='blue'
>
{confirmText || 'Proceed'}
</Button>
</ModalFooter>

View File

@ -1,136 +1,44 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useState } from 'react';
import {
Tag,
TagLabel,
TagCloseButton,
Input,
Box,
Flex,
IconButton,
BoxProps,
InputGroup,
Text,
Stack,
InputRightElement,
Avatar,
} from '@chakra-ui/react';
import BaseModal from '@educt/components/Modal';
import { MdGroup } from 'react-icons/md';
import { AddIcon, SearchIcon } from '@chakra-ui/icons';
import { UserServiceInstance } from '@educt/services';
import { UserRoleEnum } from '@educt/enums';
import SelectUsersInput from '@educt/components/SelectUsersInput';
import { IUser } from '@educt/interfaces';
import { useErrorHandler } from 'react-error-boundary';
import { BeatLoader } from 'react-spinners';
import { Virtuoso } from 'react-virtuoso';
import { FetchUsersParamsType } from '@educt/types';
import { CourseServiceInstance } from '@educt/services';
import { useToast } from '@chakra-ui/react';
import useIsMountedRef from '@educt/hooks/useIsMountedRef';
type AddStudentsModalPropsType = {
isOpen: boolean;
onClose: () => void;
courseId: string;
};
const SelectUsersInput: React.FC<BoxProps> = props => {
const AddStudentsModal: React.FC<AddStudentsModalPropsType> = ({ isOpen, onClose, courseId }) => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [search, setSearch] = useState<string>('');
const [selected, setSelected] = useState<Array<IUser>>([]);
const [users, setUsers] = useState<Array<IUser>>([]);
const [limit, setLimit] = useState<number>(5);
const filtered = users.filter(u => selected.every(s => s.id !== u.id));
const handleError = useErrorHandler();
const isEmpty = filtered.length === 0;
const isMountedRef = useIsMountedRef();
const [students, setStudents] = useState<Array<IUser>>([]);
const toast = useToast();
const fetch = async (params?: FetchUsersParamsType | undefined) => {
const handleSelectStudents = (students: Array<IUser>) => setStudents(students);
const handleAttachStudents = async () => {
if (students.length !== 0) {
try {
setIsLoading(true);
const { data } = await UserServiceInstance.fetchAll({ search, limit });
setUsers(data);
const ids = students.map(s => s.id);
await CourseServiceInstance.attachStudentsList(courseId, ids);
toast({ title: 'Students successfully attached.', status: 'success' });
onClose();
} catch (error: any) {
handleError(error);
throw error;
} finally {
setIsLoading(false);
if (isMountedRef.current) setIsLoading(false);
}
}
};
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value);
const handleSelect = (user: IUser) => () => setSelected(prev => [...prev, user]);
const handleRemoveSelected = (id: IUser['id']) => () => setSelected(prev => prev.filter(u => u.id !== id));
useEffect(() => {
fetch();
}, [search]);
return (
<Box {...props}>
{selected.length !== 0 && (
<Box mt='1'>
{selected.map(selected => (
<Tag key={selected.id} size='md' colorScheme='blue' borderRadius='full' mr='1' mb='1'>
<Avatar size='xs' name={selected.fullname} ml={-1} mr={2} />
<TagLabel>{selected.fullname}</TagLabel>
<TagCloseButton onClick={handleRemoveSelected(selected.id)} />
</Tag>
))}
</Box>
)}
<InputGroup mt='2'>
<Input
value={search}
onFocus={() => fetch()}
onChange={handleSearch}
type='text'
placeholder='Search for users...'
/>
<InputRightElement pointerEvents='none' children={<SearchIcon color='gray.300' />} />
</InputGroup>
{isLoading && (
<Text as='small' color='gray.500'>
Loading... &nbsp;
<BeatLoader size={6} color='gray' />
</Text>
)}
{!isLoading && isEmpty && (
<Text as='small' color='red.500'>
Cannot find any user
</Text>
)}
{!isLoading && !isEmpty && (
<Stack borderWidth='1px' mt='2' borderRadius='md'>
<Virtuoso
style={{ height: filtered.length * 54, maxHeight: 216 }}
data={filtered}
itemContent={(index, user) => (
<Flex key={user.id} borderBottomWidth='1px' p='7px 16px' alignItems='center'>
<Avatar name={user.fullname} size='xs' mr={{ base: '0', sm: '3' }} mb={{ base: '1', sm: '0' }} />
<Box textAlign={{ base: 'center', sm: 'left' }}>
<Text fontWeight='medium' fontSize='sm'>
{user.fullname}
</Text>
<Text color='gray.500' fontSize='xs' overflowWrap='anywhere'>
{user.email}
</Text>
</Box>
<IconButton
onClick={handleSelect(user)}
aria-label='Add student'
icon={<AddIcon />}
ml='auto'
borderRadius='full'
size='sm'
/>
</Flex>
)}
/>
</Stack>
)}
</Box>
);
};
const AddStudentsModal: React.FC<AddStudentsModalPropsType> = ({ isOpen, onClose }) => {
return (
<BaseModal
icon={MdGroup}
@ -139,8 +47,15 @@ const AddStudentsModal: React.FC<AddStudentsModalPropsType> = ({ isOpen, onClose
confirmText='Add students'
isOpen={isOpen}
onClose={onClose}
onProceed={handleAttachStudents}
isLoading={isLoading}
loadingText='Saving...'
>
<SelectUsersInput />
<SelectUsersInput
onSelect={selected => handleSelectStudents(selected)}
placeholder='Search for students...'
searchParams={{ role: UserRoleEnum.STUDENT }}
/>
</BaseModal>
);
};

View File

@ -0,0 +1,143 @@
import React, { useEffect, useState } from 'react';
import {
Tag,
TagLabel,
TagCloseButton,
Input,
Box,
Flex,
IconButton,
InputGroup,
Text,
Stack,
InputRightElement,
Avatar,
} from '@chakra-ui/react';
import { AddIcon, SearchIcon } from '@chakra-ui/icons';
import { UserServiceInstance } from '@educt/services';
import { IUser } from '@educt/interfaces';
import { useErrorHandler } from 'react-error-boundary';
import { BeatLoader } from 'react-spinners';
import { Virtuoso } from 'react-virtuoso';
import { FetchUsersParamsType } from '@educt/types';
type SelectUsersInputPropsType = {
placeholder?: string | undefined;
searchParams?: FetchUsersParamsType | undefined;
onSelect: (selected: Array<IUser>) => void;
};
const SelectUsersInput: React.FC<SelectUsersInputPropsType> = ({ placeholder, searchParams: params, onSelect }) => {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [search, setSearch] = useState<string>('');
const [selected, setSelected] = useState<Array<IUser>>([]);
const [users, setUsers] = useState<Array<IUser>>([]);
const filtered = users.filter(u => selected.every(s => s.id !== u.id));
const handleError = useErrorHandler();
const isEmpty = filtered.length === 0;
const fetch = async () => {
try {
setIsLoading(true);
const { data } = await UserServiceInstance.fetchAll({ search, ...params });
setUsers(data);
} catch (error: any) {
handleError(error);
} finally {
setIsLoading(false);
}
};
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value);
const handleSelect = (user: IUser) => () => {
setSelected(prev => {
const updated = [...prev, user];
onSelect(updated);
return updated;
});
};
const handleRemoveSelected = (id: IUser['id']) => () => {
setSelected(prev => {
const updated = prev.filter(u => u.id !== id);
onSelect(updated);
return updated;
});
};
useEffect(() => {
fetch();
}, [search]);
return (
<Box>
{selected.length !== 0 && (
<Box mt='1'>
{selected.map(selected => (
<Tag key={selected.id} size='md' colorScheme='blue' borderRadius='full' mr='1' mb='1'>
<Avatar size='xs' name={selected.fullname} ml={-1} mr={2} />
<TagLabel>{selected.fullname}</TagLabel>
<TagCloseButton onClick={handleRemoveSelected(selected.id)} />
</Tag>
))}
</Box>
)}
<InputGroup mt='2'>
<Input
value={search}
onFocus={fetch}
onChange={handleSearch}
type='text'
placeholder={placeholder || 'Search for users...'}
/>
<InputRightElement pointerEvents='none' children={<SearchIcon color='gray.300' />} />
</InputGroup>
{isLoading && (
<Text as='small' color='gray.500'>
Loading... &nbsp;
<BeatLoader size={6} color='gray' />
</Text>
)}
{!isLoading && isEmpty && (
<Text as='small' color='red.500'>
Cannot find any user
</Text>
)}
{!isLoading && !isEmpty && (
<Stack borderWidth='1px' mt='2' borderRadius='md'>
<Virtuoso
style={{ height: filtered.length * 54, maxHeight: 216 }}
data={filtered}
itemContent={(i, user) => (
<Flex key={user.id} borderBottomWidth='1px' p='7px 16px' alignItems='center'>
<Avatar name={user.fullname} size='xs' mr={{ base: '0', sm: '3' }} mb={{ base: '1', sm: '0' }} />
<Box textAlign={{ base: 'center', sm: 'left' }}>
<Text fontWeight='medium' fontSize='sm'>
{user.fullname}
</Text>
<Text color='gray.500' fontSize='xs' overflowWrap='anywhere'>
{user.email}
</Text>
</Box>
<IconButton
onClick={handleSelect(user)}
aria-label='Add student'
icon={<AddIcon />}
ml='auto'
borderRadius='full'
size='sm'
/>
</Flex>
)}
/>
</Stack>
)}
</Box>
);
};
export default SelectUsersInput;

View File

@ -0,0 +1,3 @@
import SelectUsersInput from './SelectUsersInput';
export default SelectUsersInput;

View File

@ -46,7 +46,14 @@ export function transformToFormData(data: object): FormData {
/**
* Append value to form-data format
*/
Object.entries(data).forEach(([key, value]) => value && formData.append(key, value));
Object.entries(data).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v, i) => formData.append(`${key}[${i}]`, v));
return;
}
formData.append(key, value);
});
return formData;
}

View File

@ -21,10 +21,13 @@ import { useDisclosure } from '@chakra-ui/react';
type StudentTableListPropsType = {
render: React.FC<StudentTableRowPropsType>;
students: ICourse['students'];
course: Omit<ICourse, 'students_count' | 'likes_count' | 'lessons_count'>;
};
const StudentTableList: React.FC<StudentTableListPropsType> = ({ render: Row, students }) => {
const StudentTableList: React.FC<StudentTableListPropsType> = props => {
const { render: Row, course } = props;
const { students } = course;
const [rows, setRows] = useState<ICourse['students']>(students);
const [selected, setSelected] = useState<ICourse['students']>([]);
const [search, setSearch] = useState<string>('');
@ -73,7 +76,7 @@ const StudentTableList: React.FC<StudentTableListPropsType> = ({ render: Row, st
if (rows.length === 0) {
return (
<>
<AddStudentsModal isOpen={isOpenAddStudentModal} onClose={onCloseAddStudentModal} />
<AddStudentsModal isOpen={isOpenAddStudentModal} onClose={onCloseAddStudentModal} courseId={course.id} />
<Box textAlign='center' mt='10'>
<Text>There are no students in the course yet</Text>
<Button mt='2' onClick={onOpenAddStudentModal} size='sm' colorScheme='blue' variant='outline'>
@ -86,7 +89,7 @@ const StudentTableList: React.FC<StudentTableListPropsType> = ({ render: Row, st
return (
<Box>
<AddStudentsModal isOpen={isOpenAddStudentModal} onClose={onCloseAddStudentModal} />
<AddStudentsModal isOpen={isOpenAddStudentModal} onClose={onCloseAddStudentModal} courseId={course.id} />
<Flex justifyContent='space-between' flexDir={{ base: 'column', lg: 'row' }}>
<Flex mb='2'>
<InputGroup mr='2' borderRadius='lg'>
@ -121,7 +124,7 @@ const StudentTableList: React.FC<StudentTableListPropsType> = ({ render: Row, st
</Tbody>
</Table>
<Text mt='4' fontSize='sm' color='gray.500'>
{students.length} students
{students.length} student(s)
</Text>
</Box>
);

View File

@ -1,7 +1,7 @@
import React from 'react';
import Helmet from 'react-helmet';
import { Redirect } from 'react-router-dom';
import { Box, Heading, Text, Tab, TabList, Flex, Tabs, TabPanels, TabPanel } from '@chakra-ui/react';
import { Box, Tab, TabList, Flex, Tabs, TabPanels, TabPanel } from '@chakra-ui/react';
/**
* Types
@ -93,7 +93,7 @@ const EditCoursePage: React.FC<IPageProps> = () => {
<LessonList course={course} />
</TabPanel>
<TabPanel>
<StudentTableList render={StudentTableRow} students={course.students} />
<StudentTableList render={StudentTableRow} course={course} />
</TabPanel>
</TabPanels>
</Tabs>

View File

@ -96,6 +96,14 @@ class CourseService {
const result = await this.api.delete(`/v1/courses/${id}`);
return result.data;
}
public async attachStudentsList(courseId: string, ids: Array<string>): Promise<IApiRespose<{}>> {
const result = await this.api.post(
`/v1/courses/${courseId}/attach-student-list`,
helpers.transformToFormData({ students: ids })
);
return result.data;
}
}
export const CourseServiceInstance = new CourseService(ApiServiceInstance.api);