mirror of
https://github.com/sergeyyarkov/educt-client.git
synced 2026-10-11 10:19:22 +03:00
feat: attach student list query
This commit is contained in:
parent
7303dd5b43
commit
5c0599fe0a
@ -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>
|
||||
|
||||
@ -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...
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
143
src/components/SelectUsersInput/SelectUsersInput.tsx
Normal file
143
src/components/SelectUsersInput/SelectUsersInput.tsx
Normal 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...
|
||||
<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;
|
||||
3
src/components/SelectUsersInput/index.ts
Normal file
3
src/components/SelectUsersInput/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import SelectUsersInput from './SelectUsersInput';
|
||||
|
||||
export default SelectUsersInput;
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user