Skip to content

Commit

Permalink
frontend(queries): refactor to unified query interface to help mainta…
Browse files Browse the repository at this point in the history
…inability (#1205)

* feat: main three user queries abstracted

* fix: bad imports for new use queries

* wip: doing removeCourseMutation

* wip: refactored hook creation and added addToUnplanned and removeCourse

* feat: updateMark, removeAllCourses, and resetUser hooks... also fixes type no-arg mutations

* feat: toggleLockedTerm, toggleSummer, updateStart and updateLength

* feat: added support for queryClient and toggleShowMarks, hideYears, showYears hooks

* feat: final few mutations

* fix: removed user/mutation imports

* wip: created most hooks, working on static hooks

* Add more static queries

* wip: finished all the static hooks

* wip: temporary fixed non-null assertion issues

* fix: replace combo extended info query with 3 separate queries

* feat: applied sensible default settings for queries

* fix: refactored graphical selector slightly to finally fix bubble highlighting

* fix: final to keys

* fix: extracted out the default options

---------

Co-authored-by: Lucas <[email protected]>
  • Loading branch information
ollibowers and lhvy authored Nov 14, 2024
1 parent 3a4ce84 commit d9440ad
Show file tree
Hide file tree
Showing 46 changed files with 841 additions and 872 deletions.
11 changes: 4 additions & 7 deletions frontend/src/components/Auth/PreventToken.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import { getUserIsSetup } from 'utils/api/userApi';
import { useUserSetupState } from 'utils/apiHooks/user';
import PageLoading from 'components/PageLoading';
import { useAppSelector } from 'hooks';
import { selectToken } from 'reducers/identitySlice';
Expand All @@ -14,11 +13,9 @@ const PreventToken = () => {
isPending,
data: isSetup,
error
} = useQuery({
queryKey: ['degree', 'isSetup'], // TODO-OLLI(pm): fix this key, including userId
queryFn: () => getUserIsSetup(token!),
enabled: token !== undefined,
refetchOnWindowFocus: 'always'
} = useUserSetupState({
allowUnsetToken: true,
queryOptions: { refetchOnWindowFocus: 'always' }
});

if (token === undefined) {
Expand Down
39 changes: 19 additions & 20 deletions frontend/src/components/Auth/RequireToken.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React, { useEffect } from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { isAxiosError } from 'axios';
import { getUserIsSetup } from 'utils/api/userApi';
import { useUserSetupState } from 'utils/apiHooks/user';
import openNotification from 'utils/openNotification';
import PageLoading from 'components/PageLoading';
import { useAppSelector } from 'hooks';
Expand All @@ -19,30 +18,30 @@ const RequireToken = ({ needSetup }: Props) => {
isPending,
data: isSetup,
error
} = useQuery({
queryKey: ['degree', 'isSetup'], // TODO-OLLI(pm): fix this key, including userId
queryFn: () => getUserIsSetup(token!),
enabled: token !== undefined,
refetchOnWindowFocus: 'always'
} = useUserSetupState({
allowUnsetToken: true,
queryOptions: { refetchOnWindowFocus: 'always' }
});
// TODO-OLLI(pm): multitab support is hard

useEffect(() => {
// TODO-OLLI(pm): wont need this when we get new notification hook
if (token === undefined || !!error) {
openNotification({
type: 'error',
message: 'Error',
description: 'You must be logged in before visiting this page 🙂'
});
} else if (isSetup === false && !!needSetup) {
openNotification({
type: 'warning',
message: 'Warning',
description: 'You must setup your degree before visiting this page 🙂'
});
if (!isPending) {
if (token === undefined || !!error) {
openNotification({
type: 'error',
message: 'Error',
description: 'You must be logged in before visiting this page 🙂'
});
} else if (isSetup === false && !!needSetup) {
openNotification({
type: 'warning',
message: 'Warning',
description: 'You must setup your degree before visiting this page 🙂'
});
}
}
}, [token, isSetup, error, needSetup]);
}, [token, isPending, isSetup, error, needSetup]);

if (token === undefined) {
return <Navigate to="/login" />;
Expand Down
21 changes: 3 additions & 18 deletions frontend/src/components/CourseCartCard/CourseCartCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,8 @@ import React from 'react';
import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { DeleteOutlined } from '@ant-design/icons';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Popconfirm, Tooltip, Typography } from 'antd';
import { removeCourse } from 'utils/api/plannerApi';
import useToken from 'hooks/useToken';
import { useRemoveCourseMutation } from 'utils/apiHooks/user';
import { addTab } from 'reducers/courseTabsSlice';
import S from './styles';

Expand All @@ -17,29 +15,16 @@ type Props = {
};

const CourseCartCard = ({ code, title }: Props) => {
const token = useToken();

const dispatch = useDispatch();
const navigate = useNavigate();
const queryClient = useQueryClient();

const remove = useRemoveCourseMutation();

const handleClick = () => {
navigate('/course-selector');
dispatch(addTab(code));
};

const remove = useMutation({
mutationFn: (courseCode: string) => removeCourse(token, courseCode),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['courses']
});
queryClient.invalidateQueries({
queryKey: ['planner']
});
}
});

return (
<S.CourseCardWrapper>
<div role="menuitem" onClick={handleClick}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Progress, Rate, Typography } from 'antd';
import { useTheme } from 'styled-components';
import { Course } from 'types/api';
import { EnrolmentCapacityData } from 'types/courseCapacity';
import { getCourseRating } from 'utils/api/unilectivesApi';
import { useCourseRatingQuery } from 'utils/apiHooks/static';
import getMostRecentPastTerm from 'utils/getMostRecentPastTerm';
import ProgressBar from 'components/ProgressBar';
import TermTag from 'components/TermTag';
Expand All @@ -31,10 +30,7 @@ const CourseAttributes = ({ course, courseCapacity }: CourseAttributesProps) =>
const sidebar = pathname === '/course-selector';
const theme = useTheme();

const ratingQuery = useQuery({
queryKey: ['courseRating', course.code],
queryFn: () => getCourseRating(course.code)
});
const ratingQuery = useCourseRatingQuery({}, course.code);
const rating = ratingQuery.data;

const { study_level: studyLevel, terms, campus, code, school, UOC } = course;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,32 +1,25 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Typography } from 'antd';
import { CoursesResponse } from 'types/userResponse';
import { getCourseInfo, getCoursePrereqs, getCoursesUnlockedWhenTaken } from 'utils/api/coursesApi';
import { getCourseTimetable } from 'utils/api/timetableApi';
import {
useCourseInfoQuery,
useCoursePrereqsQuery,
useCourseTimetableQuery
} from 'utils/apiHooks/static';
import { useUserCoursesUnlockedWhenTaken } from 'utils/apiHooks/user';
import getEnrolmentCapacity from 'utils/getEnrolmentCapacity';
import { unwrapSettledPromise } from 'utils/queryUtils';
import {
LoadingCourseDescriptionPanel,
LoadingCourseDescriptionPanelSidebar
} from 'components/LoadingSkeleton';
import PlannerButton from 'components/PlannerButton';
import useToken from 'hooks/useToken';
import CourseAttributes from './CourseAttributes';
import CourseInfoDrawers from './CourseInfoDrawers';
import S from './styles';

const { Title, Text } = Typography;

const getCourseExtendedInfo = async (courseCode: string) => {
return Promise.allSettled([
getCourseInfo(courseCode),
getCoursePrereqs(courseCode),
getCourseTimetable(courseCode)
]);
};

type CourseDescriptionPanelProps = {
className?: string;
courseCode: string;
Expand All @@ -40,33 +33,34 @@ const CourseDescriptionPanel = ({
onCourseClick,
courses
}: CourseDescriptionPanelProps) => {
const token = useToken();

const coursesUnlockedQuery = useQuery({
queryKey: ['courses', 'coursesUnlockedWhenTaken', courseCode],
queryFn: () => getCoursesUnlockedWhenTaken(token, courseCode)
});

const { pathname } = useLocation();
const sidebar = pathname === '/course-selector';

const courseInfoQuery = useQuery({
queryKey: ['courseInfo', courseCode],
queryFn: () => getCourseExtendedInfo(courseCode)
});
const coursesUnlockedQuery = useUserCoursesUnlockedWhenTaken({}, courseCode);

const courseQuery = useCourseInfoQuery({}, courseCode);
const coursePrereqsQuery = useCoursePrereqsQuery({}, courseCode);
const courseCapacityQuery = useCourseTimetableQuery(
{ queryOptions: { select: getEnrolmentCapacity, retry: 1, enabled: sidebar } }, // retry only once because we have bad error handling
courseCode
);

const loadingWrapper = (
<S.Wrapper $sidebar={sidebar}>
{!sidebar ? <LoadingCourseDescriptionPanelSidebar /> : <LoadingCourseDescriptionPanel />}
</S.Wrapper>
);

if (courseInfoQuery.isPending || !courseInfoQuery.isSuccess) return loadingWrapper;
if (
courseQuery.isPending ||
coursePrereqsQuery.isPending ||
(sidebar && courseCapacityQuery.isPending)
)
return loadingWrapper;

const [courseRes, pathFromRes, courseCapRes] = courseInfoQuery.data;
const course = unwrapSettledPromise(courseRes);
const coursesPathFrom = unwrapSettledPromise(pathFromRes)?.courses;
const courseCapacity = getEnrolmentCapacity(unwrapSettledPromise(courseCapRes));
const course = courseQuery.data;
const coursesPathFrom = coursePrereqsQuery.data?.courses;
const courseCapacity = courseCapacityQuery.data;

// course wasn't fetchable (fatal; should do proper error handling instead of indefinitely loading)
if (!course) return loadingWrapper;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Typography } from 'antd';
import { Course, CoursesUnlockedWhenTaken } from 'types/api';
import { CourseList } from 'types/courses';
import { badCourses, badValidations } from 'types/userResponse';
import { validateTermPlanner } from 'utils/api/plannerApi';
import { getUserCourses } from 'utils/api/userApi';
import { useUserCourses, useUserTermValidations } from 'utils/apiHooks/user';
import Collapsible from 'components/Collapsible';
import CourseTag from 'components/CourseTag';
import PrerequisiteTree from 'components/PrerequisiteTree';
import { inDev } from 'config/constants';
import useToken from 'hooks/useToken';
import S from './styles';

const { Text } = Typography;
Expand All @@ -28,25 +25,16 @@ const CourseInfoDrawers = ({
pathFrom = [],
unlocked
}: CourseInfoDrawersProps) => {
const token = useToken();

const courses =
useQuery({
queryKey: ['courses'],
queryFn: () => getUserCourses(token)
}).data || badCourses;
const courses = useUserCourses().data || badCourses;

const pathFromInPlanner = pathFrom.filter((courseCode) =>
Object.keys(courses).includes(courseCode)
);
const pathFromNotInPlanner = pathFrom.filter(
(courseCode) => !Object.keys(courses).includes(courseCode)
);
const inPlanner = courses[course.code];
const validateQuery = useQuery({
queryKey: ['validate'],
queryFn: () => validateTermPlanner(token)
});
const inPlanner = !!courses[course.code];
const validateQuery = useUserTermValidations();
const validations = validateQuery.data ?? badValidations;
const isUnlocked = validations.courses_state[course.code];
return (
Expand Down
23 changes: 9 additions & 14 deletions frontend/src/components/CourseSearchBar/CourseSearchBar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import React, { useEffect, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Flex, Select, Spin, Typography } from 'antd';
import { SearchCourse } from 'types/api';
import { CoursesResponse } from 'types/userResponse';
import { useDebounce } from 'use-debounce';
import { searchCourse } from 'utils/api/coursesApi';
import { addToUnplanned, removeCourse } from 'utils/api/plannerApi';
import { useAddToUnplannedMutation, useRemoveCourseMutation } from 'utils/apiHooks/user';
import QuickAddCartButton from 'components/QuickAddCartButton';
import useToken from 'hooks/useToken';

Expand Down Expand Up @@ -49,25 +48,21 @@ const CourseSearchBar = ({ onSelectCallback, style, userCourses }: CourseSearchB

const isInPlanner = (courseCode: string) => userCourses?.[courseCode] !== undefined;

const queryClient = useQueryClient();
const courseMutation = useMutation({
mutationFn: async (courseId: string) => {
const handleMutation = isInPlanner(courseId) ? removeCourse : addToUnplanned;
await handleMutation(token, courseId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['courses'] });
await queryClient.invalidateQueries({ queryKey: ['planner'] });
}
});
const removeCourseMutation = useRemoveCourseMutation();
const addToUnplannedMutation = useAddToUnplannedMutation();

const courseMutation = (courseId: string) =>
isInPlanner(courseId)
? removeCourseMutation.mutate(courseId)
: addToUnplannedMutation.mutate(courseId);

const courses = Object.entries(searchResults).map(([courseCode, courseTitle]) => ({
label: (
<SearchResultLabel
courseCode={courseCode}
courseTitle={courseTitle}
isPlanned={isInPlanner(courseCode)}
runMutate={courseMutation.mutate}
runMutate={courseMutation}
/>
),
value: courseCode
Expand Down
25 changes: 2 additions & 23 deletions frontend/src/components/EditMarkModal/EditMarkModal.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, message } from 'antd';
import { CourseMark } from 'types/api';
import { Grade } from 'types/planner';
import { updateCourseMark } from 'utils/api/plannerApi';
import useToken from 'hooks/useToken';
import { useUpdateMarkMutation } from 'utils/apiHooks/user';
import S from './styles';

type Props = {
Expand All @@ -14,9 +11,7 @@ type Props = {
};

const EditMarkModal = ({ code, open, onCancel }: Props) => {
const queryClient = useQueryClient();
const [markValue, setMarkValue] = useState<string | number | undefined>();
const token = useToken();

const letterGrades: Grade[] = ['SY', 'FL', 'PS', 'CR', 'DN', 'HD'];

Expand All @@ -26,23 +21,7 @@ const EditMarkModal = ({ code, open, onCancel }: Props) => {
setMarkValue(value);
};

const updateMarkMutation = useMutation({
mutationFn: (courseMark: CourseMark) => updateCourseMark(token, courseMark),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['courses']
});
queryClient.invalidateQueries({
queryKey: ['validate']
});
onCancel();
message.success('Mark Updated');
},
onError: (err) => {
// eslint-disable-next-line no-console
console.error('Error at updateMarkMutation:', err);
}
});
const updateMarkMutation = useUpdateMarkMutation();

const handleUpdateMark = () => {
if (!Number.isNaN(parseInt(markValue as string, 10))) {
Expand Down
Loading

0 comments on commit d9440ad

Please sign in to comment.