diff --git a/client/src/components/useLoading.tsx b/client/src/components/useLoading.tsx deleted file mode 100644 index 09bcd5548..000000000 --- a/client/src/components/useLoading.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useState } from 'react'; -import { message } from 'antd'; - -type CatchHandler = (e?: unknown) => void; - -export function useLoading( - value = false, - catchHandler: CatchHandler = () => { - message.error('An unexpected error occurred. Please try later.'); - }, -) { - const [loading, setLoading] = useState(value); - const wrapper = - (action: (...args: T) => Promise) => - async (...args: Parameters) => { - try { - setLoading(true); - return await action(...args); - } catch (e) { - catchHandler(e); - } finally { - setLoading(false); - } - }; - return [loading, wrapper] as const; -} diff --git a/client/src/modules/AutoTest/hooks/useVerificationsAnswers/useVerificationsAnswers.ts b/client/src/modules/AutoTest/hooks/useVerificationsAnswers/useVerificationsAnswers.ts index f6221c452..54a1d44af 100644 --- a/client/src/modules/AutoTest/hooks/useVerificationsAnswers/useVerificationsAnswers.ts +++ b/client/src/modules/AutoTest/hooks/useVerificationsAnswers/useVerificationsAnswers.ts @@ -1,24 +1,33 @@ import { message } from 'antd'; +import { useRequest } from 'ahooks'; import { useState } from 'react'; import { CourseTaskVerificationsApi, TaskVerificationAttemptDto } from '@client/api'; import { AxiosError } from 'axios'; -import { useLoading } from '@client/components/useLoading'; export function useVerificationsAnswers(courseId: number, courseTaskId: number) { const [answers, setAnswers] = useState(null); - const [loading, withLoading] = useLoading(false, e => { - const error = e as AxiosError; - message.error(error.response?.data?.message || error?.message); - }); - - const showAnswers = withLoading(async () => { - const result = await new CourseTaskVerificationsApi().getAnswers(courseId, courseTaskId); - setAnswers(result.data); - }); + const showAnswersRequest = useRequest( + async () => { + const result = await new CourseTaskVerificationsApi().getAnswers(courseId, courseTaskId); + setAnswers(result.data); + }, + { + manual: true, + onError: e => { + const error = e as AxiosError; + message.error(error.response?.data?.message || error?.message); + }, + }, + ); const hideAnswers = () => { setAnswers(null); }; - return { loading, answers, showAnswers, hideAnswers }; + return { + loading: showAnswersRequest.loading, + answers, + showAnswers: showAnswersRequest.runAsync, + hideAnswers, + }; } diff --git a/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.tsx b/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.tsx index 64402cabb..ce6b5eaa2 100644 --- a/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.tsx +++ b/client/src/modules/Interviews/pages/StageInterviewFeedback/StepContext.tsx @@ -1,7 +1,7 @@ import { CoursesInterviewsApi, InterviewFeedbackDto, ProfileCourseDto } from '@client/api'; import { FeedbackStep, Feedback } from '@client/data/interviews/technical-screening'; import { createContext, PropsWithChildren, useCallback, useMemo, useState } from 'react'; -import { useLoading } from '@client/components/useLoading'; +import { useRequest } from 'ahooks'; import { message } from 'antd'; import { useRouter } from 'next/router'; import { @@ -35,10 +35,6 @@ export const StepContext = createContext({} as StepApi); export function StepContextProvider(props: PropsWithChildren) { const { interviewFeedback, children, course, interviewId, type, interviewMaxScore } = props; const router = useRouter(); - const [loading, withLoading] = useLoading(false, error => { - message.error('An unexpected error occurred. Please try later.'); - throw error; - }); const [feedback, setFeedback] = useState(() => getFeedbackFromTemplate(interviewFeedback, interviewMaxScore), @@ -51,28 +47,37 @@ export function StepContextProvider(props: PropsWithChildren) { ); const isFinalStep = activeStepIndex === feedback.steps.length - 1 || isFinished; - const saveFeedback = withLoading(async (values: InterviewFeedbackValues) => { - const { feedbackValues, steps, isCompleted, score, decision, isGoodCandidate } = getUpdatedFeedback({ - feedback, - newValues: values, - activeStepIndex, - interviewMaxScore, - }); - await new CoursesInterviewsApi().createInterviewFeedback(course.id, interviewId, type, { - isCompleted, - score, - decision, - isGoodCandidate, - json: feedbackValues, - version: feedback.version, - }); + const saveFeedbackRequest = useRequest( + async (values: InterviewFeedbackValues) => { + const { feedbackValues, steps, isCompleted, score, decision, isGoodCandidate } = getUpdatedFeedback({ + feedback, + newValues: values, + activeStepIndex, + interviewMaxScore, + }); + await new CoursesInterviewsApi().createInterviewFeedback(course.id, interviewId, type, { + isCompleted, + score, + decision, + isGoodCandidate, + json: feedbackValues, + version: feedback.version, + }); - setFeedback({ - isCompleted, - steps, - version: feedback.version, - }); - }); + setFeedback({ + isCompleted, + steps, + version: feedback.version, + }); + }, + { + manual: true, + onError: error => { + message.error('An unexpected error occurred. Please try later.'); + throw error; + }, + }, + ); const onValuesChange = useCallback( (_: Partial, values: InterviewFeedbackValues) => { @@ -86,7 +91,7 @@ export function StepContextProvider(props: PropsWithChildren) { const next = useCallback( async (values: InterviewFeedbackValues) => { try { - await saveFeedback(values); + await saveFeedbackRequest.runAsync(values); } catch { return; } @@ -123,10 +128,10 @@ export function StepContextProvider(props: PropsWithChildren) { next, prev, onValuesChange, - loading, + loading: saveFeedbackRequest.loading, isFinalStep, }), - [activeStepIndex, feedback.steps, isFinalStep, loading, onValuesChange], + [activeStepIndex, feedback.steps, isFinalStep, onValuesChange, saveFeedbackRequest.loading], ); return {children}; diff --git a/client/src/modules/Mentor/components/ReviewRandomTask/ReviewRandomTask.tsx b/client/src/modules/Mentor/components/ReviewRandomTask/ReviewRandomTask.tsx index cfbb7c1ef..58013beb6 100644 --- a/client/src/modules/Mentor/components/ReviewRandomTask/ReviewRandomTask.tsx +++ b/client/src/modules/Mentor/components/ReviewRandomTask/ReviewRandomTask.tsx @@ -1,5 +1,5 @@ import { Button, message } from 'antd'; -import { useLoading } from '@client/components/useLoading'; +import { useRequest } from 'ahooks'; import { MentorsApi } from '@client/api'; import { AxiosError } from 'axios'; import { EyeOutlined } from '@ant-design/icons'; @@ -11,22 +11,32 @@ interface Props { } function ReviewRandomTask({ mentorId, courseId, onClick }: Props) { - const [loading, withLoading] = useLoading(false, e => { - const error = e as AxiosError; + const reviewRandomTaskRequest = useRequest( + async () => { + const service = new MentorsApi(); + await service.getRandomTask(mentorId, courseId); + onClick(); + }, + { + manual: true, + onError: e => { + const error = e as AxiosError; - if (error.response?.status === 404) { - message.info('Task for review was not found. Please try later.'); - } - }); - - const handleClick = withLoading(async () => { - const service = new MentorsApi(); - await service.getRandomTask(mentorId, courseId); - onClick(); - }); + if (error.response?.status === 404) { + message.info('Task for review was not found. Please try later.'); + } + }, + }, + ); return ( - ); diff --git a/client/src/modules/Mentor/pages/InterviewWaitingList/index.tsx b/client/src/modules/Mentor/pages/InterviewWaitingList/index.tsx index 0c1511047..893fcbbbc 100644 --- a/client/src/modules/Mentor/pages/InterviewWaitingList/index.tsx +++ b/client/src/modules/Mentor/pages/InterviewWaitingList/index.tsx @@ -1,4 +1,4 @@ -import { Button, Table } from 'antd'; +import { Button, message, Table } from 'antd'; import { PageLayout } from '@client/shared/components/PageLayout'; import { getColumnSearchProps, @@ -9,9 +9,7 @@ import { PersonCell, dateRenderer, } from '@client/shared/components/Table'; -import { useLoading } from '@client/components/useLoading'; import { useMemo, useState, useContext } from 'react'; -import { useAsync } from 'react-use'; import { CourseService } from '@client/services/course'; import { CoursePageProps } from '@client/services/models'; import { isCourseManager, isMentor } from '@client/domain/user'; @@ -34,48 +32,88 @@ export function InterviewWaitingList() { const courseId = course.id; const router = useRouter(); const interviewId = Number(router.query.interviewId); + const isInterviewReady = router.isReady && Number.isFinite(interviewId); const isPowerUser = useMemo(() => isCourseManager(session, courseId), [session, courseId]); - const [loading, withLoading] = useLoading(false); const [availableStudents, setAvailableStudents] = useState([]); const courseService = useMemo(() => new CourseService(courseId), [courseId]); - const { data: interview } = useRequest(async () => { - const { data } = await api.getInterview(interviewId, courseId); - const isStage = data.type === TaskDtoTypeEnum.StageInterview; - if (!isStage && dayjs(data.startDate).isAfter(dayjs())) { - router.push(`/403`); - } - return data; - }); + const interviewRequest = useRequest( + async () => { + const { data } = await api.getInterview(interviewId, courseId); + const isStage = data.type === TaskDtoTypeEnum.StageInterview; + if (!isStage && dayjs(data.startDate).isAfter(dayjs())) { + router.push(`/403`); + } + return data; + }, + { ready: isInterviewReady }, + ); + const interview = interviewRequest.data; const isStageInterview = interview?.type === TaskDtoTypeEnum.StageInterview; - useAsync( - withLoading(async () => { + const availableStudentsRequest = useRequest( + async () => { const { data } = await api.getAvailableStudents(courseId, interviewId); setAvailableStudents(data); - }), - [], + }, + { + ready: isInterviewReady, + refreshDeps: [courseId, interviewId], + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, ); - const inviteStudent = withLoading(async (githubId: string) => { - if (isStageInterview) { - await courseService.createInterview(githubId, session.githubId); - } else { - await courseService.addInterviewPair(`${interviewId}`, session.githubId, githubId); - } - removeStudentFromList(githubId); - }); + const inviteStudentRequest = useRequest( + async (githubId: string) => { + if (isStageInterview) { + await courseService.createInterview(githubId, session.githubId); + } else { + await courseService.addInterviewPair(`${interviewId}`, session.githubId, githubId); + } + removeStudentFromList(githubId); + }, + { + manual: true, + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); - const assignStudentToMentor = withLoading(async (studentId: string) => { - await courseService.updateStudent(studentId, { mentorGithuId: session.githubId }); - removeStudentFromList(studentId); - }); + const assignStudentToMentorRequest = useRequest( + async (studentId: string) => { + await courseService.updateStudent(studentId, { mentorGithuId: session.githubId }); + removeStudentFromList(studentId); + }, + { + manual: true, + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); + + const removeFromListRequest = useRequest( + async (githubId: string) => { + await courseService.updateMentoringAvailability(githubId, false); + removeStudentFromList(githubId); + }, + { + manual: true, + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); - const removeFromList = withLoading(async (githubId: string) => { - await courseService.updateMentoringAvailability(githubId, false); - removeStudentFromList(githubId); - }); + const loading = + availableStudentsRequest.loading || + inviteStudentRequest.loading || + assignStudentToMentorRequest.loading || + removeFromListRequest.loading; return ( @@ -152,7 +190,7 @@ export function InterviewWaitingList() { } okText="Yes" - onConfirm={() => inviteStudent(record.githubId)} + onConfirm={() => inviteStudentRequest.runAsync(record.githubId)} > @@ -164,7 +202,7 @@ export function InterviewWaitingList() { } okText="Yes" - onConfirm={() => assignStudentToMentor(record.githubId)} + onConfirm={() => assignStudentToMentorRequest.runAsync(record.githubId)} > @@ -173,7 +211,7 @@ export function InterviewWaitingList() { Are you sure to remove {record.githubId} from the wait list?} okText="Yes" - onConfirm={() => removeFromList(record.githubId)} + onConfirm={() => removeFromListRequest.runAsync(record.githubId)} > diff --git a/client/src/modules/Mentor/pages/Interviews/components/InterviewsList.tsx b/client/src/modules/Mentor/pages/Interviews/components/InterviewsList.tsx index e3e621ded..f591c2ed8 100644 --- a/client/src/modules/Mentor/pages/Interviews/components/InterviewsList.tsx +++ b/client/src/modules/Mentor/pages/Interviews/components/InterviewsList.tsx @@ -1,4 +1,5 @@ -import { Alert, Spin } from 'antd'; +import { Alert, message, Spin } from 'antd'; +import { useRequest } from 'ahooks'; import InfoCircleTwoTone from '@ant-design/icons/InfoCircleTwoTone'; import { useState } from 'react'; import { MentorInterview } from '@client/services/course'; @@ -6,7 +7,6 @@ import { StudentInterview } from './StudentInterview'; import { InterviewsSummary } from './InterviewsSummary'; import { InterviewDto, TaskDtoTypeEnum } from '@client/api'; import { Course } from '@client/services/models'; -import { useLoading } from '@client/components/useLoading'; import { useAsyncFn } from 'react-use'; import styles from './InterviewsList.module.css'; @@ -22,8 +22,13 @@ export function InterviewsList(props: StudentsListProps) { const template = interviewTask.attributes?.template; const [isExpanded, setIsExpanded] = useState(false); - const [loading, withLoading] = useLoading(); - const [, reloadList] = useAsyncFn(withLoading(fetchStudentInterviews)); + const reloadListRequest = useRequest(async () => fetchStudentInterviews(), { + manual: true, + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }); + const [, reloadList] = useAsyncFn(reloadListRequest.runAsync); if (!interviews.length) { return ( @@ -37,7 +42,7 @@ export function InterviewsList(props: StudentsListProps) { } return ( - +
([]); const [interviewsByTask, setInterviewsByTask] = useState>({}); - const [loading, withLoading] = useLoading(); const fetchStudentInterviews = useCallback(async () => { const interviews = await new CourseService(course.id).getMentorInterviews(session.githubId); setInterviewsByTask(groupBy(interviews, 'name')); }, [course.id, session.githubId]); - const loadData = async () => { - const [{ data }] = await Promise.all([ - new CoursesInterviewsApi().getInterviews(course.id, false, [ - TaskDtoTypeEnum.Interview, - TaskDtoTypeEnum.StageInterview, - ]), - fetchStudentInterviews(), - ]); + const interviewsRequest = useRequest( + async () => { + const [{ data }] = await Promise.all([ + new CoursesInterviewsApi().getInterviews(course.id, false, [ + TaskDtoTypeEnum.Interview, + TaskDtoTypeEnum.StageInterview, + ]), + fetchStudentInterviews(), + ]); - setInterviews(data); - }; - - useAsync(withLoading(loadData), []); + return data; + }, + { + refreshDeps: [course.id, fetchStudentInterviews], + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); + const interviews = interviewsRequest.data ?? []; return ( - +
{interviews.map(interviewTask => ( diff --git a/client/src/modules/MentorRegistry/components/InviteMentorsModal.tsx b/client/src/modules/MentorRegistry/components/InviteMentorsModal.tsx index a9f964967..450a38c10 100644 --- a/client/src/modules/MentorRegistry/components/InviteMentorsModal.tsx +++ b/client/src/modules/MentorRegistry/components/InviteMentorsModal.tsx @@ -1,8 +1,7 @@ import { Alert, Checkbox, Form, Select, Space, Spin } from 'antd'; -import { useAsync } from 'react-use'; +import { useRequest } from 'ahooks'; import { InviteMentorsDto } from '@client/api'; import { ModalForm } from '@client/shared/components/Forms'; -import { useLoading } from '@client/components/useLoading'; import ReactQuill from 'react-quill'; import { MentorRegistryService } from '@client/services/mentorRegistry'; import { DisciplinesApi } from '@client/api'; @@ -18,20 +17,34 @@ const disciplinesApi = new DisciplinesApi(); function InviteMentorsModal({ onCancel }: Props) { const { message } = useMessage(); - const [loading, withLoading] = useLoading(false); - const submit = withLoading(async (data: InviteMentorsDto) => { - await mentorRegistryService.inviteMentors(data); - message.success('Invitation successfully send.'); - onCancel(); - }); + const submitRequest = useRequest( + async (data: InviteMentorsDto) => { + await mentorRegistryService.inviteMentors(data); + message.success('Invitation successfully send.'); + onCancel(); + }, + { + manual: true, + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); - const { loading: disciplinesLoading, value: disciplines = [] } = useAsync(async () => { + const disciplinesRequest = useRequest(async () => { const { data } = await disciplinesApi.getDisciplines(); return data; - }, []); + }); + const disciplines = disciplinesRequest.data ?? []; return ( - + : null} + notFoundContent={disciplinesRequest.loading ? : null} > {disciplines.map(discipline => ( diff --git a/client/src/modules/MentorTasksReview/pages/MentorTasksReview.tsx b/client/src/modules/MentorTasksReview/pages/MentorTasksReview.tsx index a16c3b2f0..9c08dc931 100644 --- a/client/src/modules/MentorTasksReview/pages/MentorTasksReview.tsx +++ b/client/src/modules/MentorTasksReview/pages/MentorTasksReview.tsx @@ -5,7 +5,6 @@ import { SorterResult } from 'antd/lib/table/interface'; import { CoursesTasksApi, CourseTaskDtoCheckerEnum, MentorReviewDto, MentorReviewsApi } from '@client/api'; import { IPaginationInfo } from '@client/shared/utils/pagination'; import { AdminPageLayout } from '@client/shared/components/PageLayout'; -import { useLoading } from '@client/components/useLoading'; import { isCourseManager } from '@client/domain/user'; import { SessionContext, useActiveCourseContext } from '@client/modules/Course/contexts'; import { useContext, useMemo, useState } from 'react'; @@ -28,7 +27,7 @@ export const MentorTasksReview = () => { const { courses, course } = useActiveCourseContext(); const session = useContext(SessionContext); - const { data: tasks } = useRequest(async () => { + const tasksRequest = useRequest(async () => { const { data } = await coursesTasksApi.getCourseTasks(course.id, undefined, CourseTaskDtoCheckerEnum.Mentor); return data; }); @@ -39,9 +38,7 @@ export const MentorTasksReview = () => { content: [], pagination: { current: 1, pageSize: 20 }, }); - const [loading, withLoading] = useLoading(false); - - const getMentorReviews = withLoading( + const mentorReviewsRequest = useRequest( async ( pagination: TablePaginationConfig, filters?: Record, @@ -67,16 +64,22 @@ export const MentorTasksReview = () => { message.error('Failed to load mentor reviews. Please try later.'); } }, + { manual: true }, ); const handleReviewerAssigned = async () => { - await getMentorReviews(reviews.pagination); + await mentorReviewsRequest.runAsync(reviews.pagination); }; - useAsync(async () => await getMentorReviews(reviews.pagination), [course]); + useAsync(async () => await mentorReviewsRequest.runAsync(reviews.pagination), [course]); return ( - + Submitted tasks @@ -87,9 +90,9 @@ export const MentorTasksReview = () => { diff --git a/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.tsx b/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.tsx index 616da0ad0..6efc09ad4 100644 --- a/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.tsx +++ b/client/src/modules/Notifications/pages/AdminNotificationsPage/AdminNotificationsSettingsPage.tsx @@ -1,8 +1,7 @@ import { useState, useMemo, useCallback, ReactNode } from 'react'; +import { useRequest } from 'ahooks'; import { Button, Spin } from 'antd'; import { NotificationsService } from '@client/modules/Notifications/services/notifications'; -import { useLoading } from '@client/components/useLoading'; -import { useAsync } from 'react-use'; import { NotificationSettingsTable } from '@client/modules/Notifications/components/NotificationSettingsTable'; import { NotificationSettingsModal } from '@client/modules/Notifications/components/NotificationSettingsModal'; import { NotificationDto } from '@client/api'; @@ -10,19 +9,14 @@ import { useMessage } from '@client/hooks'; export function AdminNotificationsPage() { const { message } = useMessage(); - const [notifications, setNotifications] = useState([]); - const [loading, withLoading] = useLoading(false); const service = useMemo(() => new NotificationsService(), []); const [modal, setModal] = useState(); - - const loadData = useCallback( - withLoading(async () => { - setNotifications(await service.getNotifications()); - }), - [], - ); - - useAsync(loadData, []); + const notificationsRequest = useRequest(async () => service.getNotifications(), { + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }); + const notifications = notificationsRequest.data ?? []; const edit = useCallback( (notification: NotificationDto) => { @@ -49,7 +43,7 @@ export function AdminNotificationsPage() { }, [notifications]); return ( - + @@ -65,11 +59,11 @@ export function AdminNotificationsPage() { ? service.saveNotification(notification) : service.createNotification(notification)); - setNotifications(notifications => { - return isSave + notificationsRequest.mutate( + isSave ? notifications.map(notification => (notification.id === data.id ? data : notification)) - : [...notifications, data]; - }); + : [...notifications, data], + ); setModal(null); message.success('New notification settings saved.'); } catch { @@ -80,7 +74,7 @@ export function AdminNotificationsPage() { async function deleteNotification(notification: NotificationDto) { try { await service.deleteNotification(notification.id); - setNotifications(notifications => notifications.filter(n => n.id !== notification.id)); + notificationsRequest.mutate(notifications.filter(n => n.id !== notification.id)); message.success('Notification is deleted.'); } catch { message.error('Failed to delete notification.'); diff --git a/client/src/modules/Notifications/pages/UserNotificationsSettingsPage.tsx b/client/src/modules/Notifications/pages/UserNotificationsSettingsPage.tsx index 241983fa5..0306688db 100644 --- a/client/src/modules/Notifications/pages/UserNotificationsSettingsPage.tsx +++ b/client/src/modules/Notifications/pages/UserNotificationsSettingsPage.tsx @@ -1,4 +1,5 @@ import { useState, useMemo, useCallback } from 'react'; +import { useRequest } from 'ahooks'; import { Button, Space } from 'antd'; import { NotificationsService, @@ -6,8 +7,6 @@ import { UserNotificationSettings, } from '@client/modules/Notifications/services/notifications'; import set from 'lodash/set'; -import { useLoading } from '@client/components/useLoading'; -import { useAsync } from 'react-use'; import { PageLayout } from '@client/shared/components/PageLayout'; import { NotificationsTable } from '../components/NotificationsUserSettingsTable'; import { Consents, Connection } from '../components/Consents'; @@ -17,15 +16,14 @@ import { useMessage } from '@client/hooks'; export function UserNotificationsPage() { const { message } = useMessage(); const [notifications, setNotifications] = useState([]); - const [loading, withLoading] = useLoading(false); const service = useMemo(() => new NotificationsService(), []); const [email, setEmail] = useState(); const [telegram, setTelegram] = useState(); const [discord, setDiscord] = useState(); const [disabledChannels, setDisabledChannels] = useState([]); - const loadData = useCallback( - withLoading(async () => { + const userNotificationsRequest = useRequest( + async () => { const { connections, notifications } = await service.getUserNotificationSettings(); setNotifications(notifications); @@ -48,12 +46,14 @@ export function UserNotificationsPage() { disabledChannels.push(NotificationChannel.discord); } setDisabledChannels(disabledChannels); - }), - [], + }, + { + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, ); - useAsync(loadData, []); - const onCheck = useCallback( async (dataIndex: string[], record: UserNotificationSettings, checked: boolean) => { const newData = [...notifications]; @@ -74,9 +74,9 @@ export function UserNotificationsPage() { const hasConnections = Object.keys(NotificationChannel).length !== disabledChannels.length; return ( - + - {!loading && } + {!userNotificationsRequest.loading && }
)} - diff --git a/client/src/modules/Students/Pages/Students.tsx b/client/src/modules/Students/Pages/Students.tsx index 2a12787d5..312602d24 100644 --- a/client/src/modules/Students/Pages/Students.tsx +++ b/client/src/modules/Students/Pages/Students.tsx @@ -1,9 +1,9 @@ import { Drawer, TablePaginationConfig, message } from 'antd'; +import { useRequest } from 'ahooks'; import { FilterValue } from 'antd/es/table/interface'; import { StudentsApi, UserStudentDto } from '@client/api'; import { IPaginationInfo } from '@client/shared/utils/pagination'; import { AdminPageLayout } from '@client/shared/components/PageLayout'; -import { useLoading } from '@client/components/useLoading'; import { useState } from 'react'; import { useAsync } from 'react-use'; import StudentsTable from '../components/StudentsTable'; @@ -26,9 +26,7 @@ export const Students = () => { }); const [activeStudent, setActiveStudent] = useState(null); - const [loading, withLoading] = useLoading(false); - - const getStudents = withLoading( + const studentsRequest = useRequest( async (pagination: TablePaginationConfig, filters?: Record) => { try { const { student, country, city, onGoingCourses, previousCourses } = filters || {}; @@ -46,15 +44,16 @@ export const Students = () => { message.error('Failed to load students list. Please try again.'); } }, + { manual: true }, ); - useAsync(async () => await getStudents(students.pagination), []); + useAsync(async () => await studentsRequest.runAsync(students.pagination), []); return ( - + >(); - const [loading, withLoading] = useLoading(false); const [showJoinTeamModal, setShowJoinTeamModal] = useState(false); @@ -56,16 +55,19 @@ function Teams() { toggleTeamModal(); }; - const distributeStudentsToTeam = withLoading(async () => { - try { - await teamDistributionApi.distributeStudentsToTeam(course.id, teamDistributionId); - } catch { - message.error('Failed to distribute students to team. Please try later.'); - } - }); + const distributeStudentsToTeamRequest = useRequest( + async () => { + try { + await teamDistributionApi.distributeStudentsToTeam(course.id, teamDistributionId); + } catch { + message.error('Failed to distribute students to team. Please try later.'); + } + }, + { manual: true }, + ); const handleDistributeStudents = async () => { - await distributeStudentsToTeam(); + await distributeStudentsToTeamRequest.runAsync(); await loadDistribution(); }; @@ -73,24 +75,27 @@ function Teams() { setShowJoinTeamModal(true); }; - const joinTeam = withLoading(async (teamId: number, record: JoinTeamDto) => { - try { - const { data: team } = await teamApi.joinTeam(course.id, teamDistributionId, teamId, record); - await loadDistribution(); - setShowJoinTeamModal(false); - modal.success({ - title: Successfully joined to the {team.name}, - content: ( -
- {team.description} -
- ), - okText: 'Next', - }); - } catch { - message.error('Failed to join to team. Please try later.'); - } - }); + const joinTeamRequest = useRequest( + async (teamId: number, record: JoinTeamDto) => { + try { + const { data: team } = await teamApi.joinTeam(course.id, teamDistributionId, teamId, record); + await loadDistribution(); + setShowJoinTeamModal(false); + modal.success({ + title: Successfully joined to the {team.name}, + content: ( +
+ {team.description} +
+ ), + okText: 'Next', + }); + } catch { + message.error('Failed to join to team. Please try later.'); + } + }, + { manual: true }, + ); const copyPassword = async (teamId: number): Promise => { const teamApi = new TeamApi(); @@ -114,34 +119,39 @@ function Teams() { } }; - const submitTeam = withLoading(async (record: CreateTeamDto, id?: number) => { - try { - if (id) { - await teamApi.updateTeam(course.id, teamDistributionId, id, record); - } else { - const { data: team } = await teamApi.createTeam(course.id, teamDistributionId, record); - modal.confirm({ - title: {team.name} is created successfully, - content: ( -
- As a team lead you get an invitation password to join members - {team.description} -
- ), - cancelText: 'Next', - cancelButtonProps: { type: 'primary' }, - onOk: () => copyPassword(team.id), - okText: 'Copy invitation password', - okButtonProps: { type: 'default' }, - icon: , - }); + const submitTeamRequest = useRequest( + async (record: CreateTeamDto, id?: number) => { + try { + if (id) { + await teamApi.updateTeam(course.id, teamDistributionId, id, record); + } else { + const { data: team } = await teamApi.createTeam(course.id, teamDistributionId, record); + modal.confirm({ + title: {team.name} is created successfully, + content: ( +
+ As a team lead you get an invitation password to join members + {team.description} +
+ ), + cancelText: 'Next', + cancelButtonProps: { type: 'primary' }, + onOk: () => copyPassword(team.id), + okText: 'Copy invitation password', + okButtonProps: { type: 'default' }, + icon: , + }); + } + await loadDistribution(); + toggleTeamModal(); + } catch { + message.error('Failed to create team. Please try later.'); } - await loadDistribution(); - toggleTeamModal(); - } catch { - message.error('Failed to create team. Please try later.'); - } - }); + }, + { manual: true }, + ); + + const loading = distributeStudentsToTeamRequest.loading || joinTeamRequest.loading || submitTeamRequest.loading; const contentRenderers = () => { if (!distribution) { @@ -186,13 +196,15 @@ function Teams() { mode={mode} isManager={isManager} data={teamData} - onSubmit={submitTeam} + onSubmit={submitTeamRequest.runAsync} onCancel={toggleTeamModal} maxStudentsCount={distribution.strictTeamSize} courseId={distribution.courseId} /> )} - {showJoinTeamModal && setShowJoinTeamModal(false)} />} + {showJoinTeamModal && ( + setShowJoinTeamModal(false)} /> + )} {distribution ? ( { + const { data } = await teamDistributionApi.getStudentsWithoutTeam( + distribution.courseId, + distribution.id, + pagination.pageSize ?? 10, + pagination.current ?? 1, + search, + ); + setStudents({ ...students, ...data }); + }, + { + manual: true, + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); const [search, setSearch] = useState(''); const onSearch = (value: string) => { setSearch(value); }; - const getStudents = withLoading(async (pagination: TablePaginationConfig) => { - const { data } = await teamDistributionApi.getStudentsWithoutTeam( - distribution.courseId, - distribution.id, - pagination.pageSize ?? 10, - pagination.current ?? 1, - search, - ); - setStudents({ ...students, ...data }); - }); - const handleDeleteStudent = async (student: TeamDistributionStudentDto) => { confirm({ title: 'Are you sure you want to remove this student?', @@ -65,7 +71,7 @@ export default function StudentsWithoutTeamSection({ distribution, isManager, re distribution.id, ); message.success('Student removed successfully'); - await getStudents(students.pagination); + await studentsRequest.runAsync(students.pagination); await reloadDistribution(); } catch { message.error('Failed to remove student. Please try again later.'); @@ -74,7 +80,7 @@ export default function StudentsWithoutTeamSection({ distribution, isManager, re }); }; - useAsync(async () => await getStudents(students.pagination), [distribution, search]); + useAsync(async () => await studentsRequest.runAsync(students.pagination), [distribution, search]); return ( @@ -89,8 +95,8 @@ export default function StudentsWithoutTeamSection({ distribution, isManager, re diff --git a/client/src/modules/Teams/components/TeamModal/TeamModal.tsx b/client/src/modules/Teams/components/TeamModal/TeamModal.tsx index be9d5a21d..2753c8986 100644 --- a/client/src/modules/Teams/components/TeamModal/TeamModal.tsx +++ b/client/src/modules/Teams/components/TeamModal/TeamModal.tsx @@ -1,7 +1,7 @@ import { Form, Input, message, Space, Typography, Modal } from 'antd'; +import { useRequest } from 'ahooks'; import { CreateTeamDto, TeamDto } from '@client/api'; import { StudentSearch } from '@client/shared/components/StudentSearch'; -import { useLoading } from '@client/components/useLoading'; import { urlPattern } from '@client/services/validators'; const { TextArea } = Input; @@ -25,7 +25,6 @@ const layout = { export default function TeamModal({ onCancel, onSubmit, data, courseId, isManager, maxStudentsCount, mode }: Props) { const [form] = Form.useForm(); - const [loading, withLoading] = useLoading(false); const createRecord = ({ name = 'Team name', @@ -41,10 +40,18 @@ export default function TeamModal({ onCancel, onSubmit, data, courseId, isManage }; }; - const handleModalSubmit = withLoading(async (values: CreateTeamDto) => { - const record = createRecord(values); - await onSubmit(record, data?.id); - }); + const handleModalSubmitRequest = useRequest( + async (values: CreateTeamDto) => { + const record = createRecord(values); + await onSubmit(record, data?.id); + }, + { + manual: true, + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); const handleChangeStudents = (value: number[]) => { if (value.length <= maxStudentsCount) { @@ -68,9 +75,9 @@ export default function TeamModal({ onCancel, onSubmit, data, courseId, isManage if (values == null) { return; } - handleModalSubmit(values); + handleModalSubmitRequest.runAsync(values); }} - okButtonProps={{ disabled: loading }} + okButtonProps={{ disabled: handleModalSubmitRequest.loading }} onCancel={() => { onCancel(); form.resetFields(); diff --git a/client/src/modules/Teams/components/TeamsSection/TeamsSection.tsx b/client/src/modules/Teams/components/TeamsSection/TeamsSection.tsx index 2b91e82cd..b712553aa 100644 --- a/client/src/modules/Teams/components/TeamsSection/TeamsSection.tsx +++ b/client/src/modules/Teams/components/TeamsSection/TeamsSection.tsx @@ -1,11 +1,11 @@ -import { Col, Input, Row, Space, Table, TablePaginationConfig, TableProps, Typography } from 'antd'; +import { Col, Input, message, Row, Space, Table, TablePaginationConfig, TableProps, Typography } from 'antd'; +import { useRequest } from 'ahooks'; import { useMemo, useState } from 'react'; import { TeamApi, TeamDistributionDetailedDto, TeamDto } from '@client/api'; import { useAsync } from 'react-use'; import { IPaginationInfo } from '@client/shared/utils/pagination'; import { getColumns, expandedRowRender } from './renderers'; -import { useLoading } from '@client/components/useLoading'; import { TeamsTableColumnKey } from '@client/modules/Teams/constants'; type Props = { @@ -34,18 +34,24 @@ export default function TeamSection({ distribution, toggleTeamModal, isManager } setSearch(value); }; - const [loading, withLoading] = useLoading(false); - - const getTeams = withLoading(async (pagination: TablePaginationConfig) => { - const { data } = await teamApi.getTeams( - distribution.courseId, - distribution.id, - pagination.pageSize ?? 10, - pagination.current ?? 1, - search, - ); - setTeams({ ...teams, ...data }); - }); + const teamsRequest = useRequest( + async (pagination: TablePaginationConfig) => { + const { data } = await teamApi.getTeams( + distribution.courseId, + distribution.id, + pagination.pageSize ?? 10, + pagination.current ?? 1, + search, + ); + setTeams({ ...teams, ...data }); + }, + { + manual: true, + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); const columns = useMemo(() => { return isManager @@ -54,10 +60,10 @@ export default function TeamSection({ distribution, toggleTeamModal, isManager } }, [isManager, distribution, toggleTeamModal]); const handleChange: TableProps['onChange'] = pagination => { - getTeams(pagination); + teamsRequest.runAsync(pagination); }; - useAsync(async () => await getTeams(teams.pagination), [distribution, search]); + useAsync(async () => await teamsRequest.runAsync(teams.pagination), [distribution, search]); return ( @@ -77,7 +83,7 @@ export default function TeamSection({ distribution, toggleTeamModal, isManager } dataSource={teams.content} columns={columns} expandable={{ expandedRowRender, rowExpandable: record => record.students.length > 0 }} - loading={loading} + loading={teamsRequest.loading} /> ); diff --git a/client/src/pages/admin/mentor-registry.tsx b/client/src/pages/admin/mentor-registry.tsx index 4b9a77bb2..0f0dd40e8 100644 --- a/client/src/pages/admin/mentor-registry.tsx +++ b/client/src/pages/admin/mentor-registry.tsx @@ -1,7 +1,7 @@ import FileExcelOutlined from '@ant-design/icons/FileExcelOutlined'; +import { useRequest } from 'ahooks'; import { Alert, Button, Col, Form, message, notification, Row, Select, Space, Tabs, Tooltip, Typography } from 'antd'; import { useCallback, useContext, useMemo, useState } from 'react'; -import { useAsync } from 'react-use'; import { DisciplineDto, DisciplinesApi, MentorRegistryDto } from '@client/api'; @@ -9,7 +9,6 @@ import { CommentModal } from '@client/shared/components/CommentModal'; import { ModalForm } from '@client/shared/components/Forms'; import { AdminPageLayout } from '@client/shared/components/PageLayout'; import { tabRenderer } from '@client/components/TabsWithCounter/renderers'; -import { useLoading } from '@client/components/useLoading'; import { SessionContext, SessionProvider } from '@client/modules/Course/contexts'; import { CombinedFilter, @@ -54,7 +53,6 @@ const coursesService = new CoursesService(); const disciplinesApi = new DisciplinesApi(); function Page() { - const [loading, withLoading] = useLoading(false); const session = useContext(SessionContext); const [api, contextHolder] = notification.useNotification(); @@ -83,53 +81,74 @@ function Page() { [MentorRegistryTabsMode.All]: 0, }); - const loadData = withLoading(async () => { - const [allData, courses] = await Promise.all([ - mentorRegistryService.getMentors({ - status: activeTab, - pageSize: PAGINATION, - currentPage, - githubId: combinedFilter.githubId?.[0] ?? undefined, - cityName: combinedFilter.cityName?.[0] ?? undefined, - preferedCourses: combinedFilter.preferredCourses?.length - ? combinedFilter.preferredCourses.map(Number) - : undefined, - preselectedCourses: combinedFilter.preselectedCourses?.length - ? combinedFilter.preselectedCourses.map(Number) - : undefined, - technicalMentoring: combinedFilter.technicalMentoring?.length ? combinedFilter.technicalMentoring : undefined, - }), - coursesService.getCourses(), - ]); - const { data: disciplines } = await disciplinesApi.getDisciplines(); - setAllData(allData.mentors); - setData(allData.mentors); - setTotal(total => ({ ...total, [activeTab]: allData.total })); - setMaxStudents(allData.mentors.reduce((sum, it) => sum + it.maxStudentsLimit, 0)); - setCourses(courses); - setDisciplines(disciplines); - }); - - const cancelMentor = withLoading(async (githubId: string) => { - setModalData(null); - await mentorRegistryService.cancelMentorRegistry(githubId); - await loadData(); - setIsModalOpen(false); - }); + const loadDataRequest = useRequest( + async () => { + const [mentorRegistryData, courses] = await Promise.all([ + mentorRegistryService.getMentors({ + status: activeTab, + pageSize: PAGINATION, + currentPage, + githubId: combinedFilter.githubId?.[0] ?? undefined, + cityName: combinedFilter.cityName?.[0] ?? undefined, + preferedCourses: combinedFilter.preferredCourses?.length + ? combinedFilter.preferredCourses.map(Number) + : undefined, + preselectedCourses: combinedFilter.preselectedCourses?.length + ? combinedFilter.preselectedCourses.map(Number) + : undefined, + technicalMentoring: combinedFilter.technicalMentoring?.length ? combinedFilter.technicalMentoring : undefined, + }), + coursesService.getCourses(), + ]); + const { data: disciplines } = await disciplinesApi.getDisciplines(); + return { courses, disciplines, mentorRegistryData }; + }, + { + refreshDeps: [activeTab, combinedFilter, currentPage], + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + onSuccess: ({ courses, disciplines, mentorRegistryData }) => { + setAllData(mentorRegistryData.mentors); + setData(mentorRegistryData.mentors); + setTotal(total => ({ ...total, [activeTab]: mentorRegistryData.total })); + setMaxStudents(mentorRegistryData.mentors.reduce((sum, it) => sum + it.maxStudentsLimit, 0)); + setCourses(courses); + setDisciplines(disciplines); + }, + }, + ); - const sendMentorRegistryComment = withLoading(async (comment: string) => { - if (!modalData?.record?.githubId) return; - try { - await mentorRegistryService.sendCommentMentorRegistry(modalData?.record?.githubId, comment); - await loadData(); - } catch { - message.error('An error occurred. Please try again later.'); - } finally { + const cancelMentorRequest = useRequest( + async (githubId: string) => { + setModalData(null); + await mentorRegistryService.cancelMentorRegistry(githubId); setIsModalOpen(false); - } - }); + }, + { + manual: true, + onSuccess: () => loadDataRequest.runAsync(), + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); + + const sendMentorRegistryCommentRequest = useRequest( + async (comment: string) => { + if (!modalData?.record?.githubId) return; + try { + await mentorRegistryService.sendCommentMentorRegistry(modalData?.record?.githubId, comment); + } catch { + message.error('An error occurred. Please try again later.'); + } finally { + setIsModalOpen(false); + } + }, + { manual: true, onSuccess: () => loadDataRequest.runAsync() }, + ); - useAsync(loadData, [combinedFilter, currentPage, activeTab]); + const loading = loadDataRequest.loading || cancelMentorRequest.loading || sendMentorRegistryCommentRequest.loading; const openNotificationWithIcon = (type: NotificationType) => { api[type]({ @@ -159,7 +178,7 @@ function Page() { }); } setModalData(null); - await loadData(); + await loadDataRequest.runAsync(); openNotificationWithIcon('success'); } catch { message.error('An error occurred. Please try again later.'); @@ -167,7 +186,7 @@ function Page() { setModalLoading(false); } }, - [modalData, openNotificationWithIcon, loadData], + [modalData, openNotificationWithIcon, loadDataRequest], ); const renderModal = useCallback(() => { @@ -216,7 +235,7 @@ function Page() { await mentorRegistryService.updateMentor(record!.githubId, { preselectedCourses: record.preselectedCourses.map(v => String(v)), }); - loadData(); + loadDataRequest.runAsync(); } catch { message.error('An error occurred. Please try again later.'); } finally { @@ -311,7 +330,7 @@ function Page() { modalData={modalData || {}} modalLoading={modalLoading} onCancel={onCancelModal} - cancelMentor={cancelMentor} + cancelMentor={cancelMentorRequest.runAsync} /> )} {isModalOpen && modalData?.mode === ModalDataMode.Comment && ( @@ -322,7 +341,7 @@ function Page() { initialValue={modalData?.record?.comment ?? undefined} availableEmptyComment={true} onOk={(comment: string) => { - sendMentorRegistryComment(comment); + sendMentorRegistryCommentRequest.runAsync(comment); }} /> )} diff --git a/client/src/pages/course/admin/interviews.tsx b/client/src/pages/course/admin/interviews.tsx index 063b1a287..4a813727b 100644 --- a/client/src/pages/course/admin/interviews.tsx +++ b/client/src/pages/course/admin/interviews.tsx @@ -1,4 +1,5 @@ -import { Button, Row, Select, Table, Popconfirm } from 'antd'; +import { Button, message, Row, Select, Table, Popconfirm } from 'antd'; +import { useRequest } from 'ahooks'; import { StudentMentorModal } from '@client/shared/components/StudentMentorModal'; import { AdminPageLayout } from '@client/shared/components/PageLayout'; import { @@ -8,14 +9,12 @@ import { PersonCell, numberSorter, } from '@client/shared/components/Table'; -import { useLoading } from '@client/components/useLoading'; import { useMemo, useState, useContext } from 'react'; import { CourseService } from '@client/services/course'; import { CourseRole } from '@client/services/models'; -import { useAsync } from 'react-use'; import { isCourseManager } from '@client/domain/user'; import { SessionContext, SessionProvider, useActiveCourseContext } from '@client/modules/Course/contexts'; -import { CoursesInterviewsApi, InterviewDto, InterviewPairDto } from '@client/api'; +import { CoursesInterviewsApi, InterviewPairDto } from '@client/api'; const coursesInterviewsApi = new CoursesInterviewsApi(); @@ -24,75 +23,105 @@ function Page() { const { course, courses } = useActiveCourseContext(); const courseId = course.id; - const [loading, withLoading] = useLoading(false); - const [interviews, setInterviews] = useState([]); - - const [data, setData] = useState([] as InterviewPairDto[]); - const [selected, setSelected] = useState(null); + const [selected, setSelected] = useState(null); const [modal, setModal] = useState(false); - const courseService = useMemo(() => new CourseService(courseId), [courseId]); + const courseService = useMemo(() => new CourseService(courseId), [courseId]); const courseManagerRole = useMemo(() => isCourseManager(session, courseId), [course, session]); + const hasSelectedInterview = selected != null; + + const interviewsRequest = useRequest( + async () => { + const { data: interviews } = await coursesInterviewsApi.getInterviews(courseId); + const filtered = interviews + .filter(({ type }) => type === 'interview') + .map(({ id, name }) => ({ label: name, value: id })); + return filtered; + }, + { + onSuccess: data => setSelected(data[0]?.value ?? null), + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); - const loadInterviews = async () => { - const { data: interviews } = await coursesInterviewsApi.getInterviews(courseId); - const filtered = interviews.filter(({ type }) => type === 'interview'); - setInterviews(filtered); - setSelected(filtered[0]?.id.toString() ?? null); - }; - - const deleteInterview = withLoading(async (record: InterviewPairDto) => { - await courseService.cancelInterviewPair(selected!, String(record.id)); - const filtered = data.filter(d => d.id !== record.id); - setData(filtered); - }); - - const loadData = async () => { - if (selected) { + const interviewPairsRequest = useRequest( + async () => { const { data } = await coursesInterviewsApi.getInterviewPairs(Number(selected), courseId); - setData(data); - } - }; + return data; + }, + { + ready: hasSelectedInterview, + refreshDeps: [selected], + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); - const createInterviews = withLoading(async () => { - if (selected) { - const courseTaskId = Number(selected); - const isInterviewsIncludesSelected = interviews.map(({ id }) => id).includes(courseTaskId); + const deleteInterviewRequest = useRequest( + async (selected: number, record: InterviewPairDto) => + courseService.cancelInterviewPair(selected, String(record.id)), + { + manual: true, + onSuccess: () => interviewPairsRequest.runAsync(), + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); - if (isInterviewsIncludesSelected) { - await courseService.createInterviewDistribution(courseTaskId); - await loadData(); - } - } - }); + const createInterviewsRequest = useRequest( + async (selected: number) => courseService.createInterviewDistribution(selected), + { + ready: hasSelectedInterview, + manual: true, + onSuccess: () => interviewPairsRequest.runAsync(), + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); - useAsync(withLoading(loadData), [selected]); + const addInterviewPairRequest = useRequest( + async (studentGithubId: string, mentorGithubId: string) => { + await courseService.addInterviewPair(String(selected), mentorGithubId, studentGithubId); + await interviewPairsRequest.runAsync(); + setModal(false); + }, + { + manual: true, + onError: () => { + message.error('An unexpected error occurred. Please try later.'); + }, + }, + ); - useAsync(withLoading(loadInterviews), []); + const loading = + deleteInterviewRequest.loading || + interviewPairsRequest.loading || + createInterviewsRequest.loading || + interviewsRequest.loading || + addInterviewPairRequest.loading; return ( - +