From 5220001a83e1e290ed8fa4c76f9ec96271ea639e Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 18:15:07 +0200 Subject: [PATCH 01/13] fix: update main layout vertical padding --- src/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 36f1147..b56038e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -113,7 +113,7 @@ const App = (): ReactElement => { - + {appRoutes.map(({ path, roles, element }) => ( Date: Sat, 21 Oct 2023 18:50:16 +0200 Subject: [PATCH 02/13] fix(courses): remove editing course using rows --- src/components/courses/data-table/index.tsx | 25 --------------------- 1 file changed, 25 deletions(-) diff --git a/src/components/courses/data-table/index.tsx b/src/components/courses/data-table/index.tsx index df2c318..b5438ba 100644 --- a/src/components/courses/data-table/index.tsx +++ b/src/components/courses/data-table/index.tsx @@ -185,27 +185,6 @@ export const CoursesDataTable = (): JSX.Element => { setRowSelection(newSelection); }; - const handleRowUpdate = async (newRow: TPureCourse, oldRow: TPureCourse) => { - const diff = differenceWith([oldRow], [newRow], isEqual); - - if (diff.length === 0) return oldRow; - - setRows((prevRows) => - unionBy([newRow], prevRows, E_COURSE_ENTITY_KEYS.ABBR), - ); - - updateMutation.mutate({ - [E_COURSE_ENTITY_KEYS.ABBR]: newRow[E_COURSE_ENTITY_KEYS.ABBR], - data: omit(newRow, [ - E_COURSE_ENTITY_KEYS.ABBR, - E_COURSE_ENTITY_KEYS.GUARANTOR, - E_COURSE_ENTITY_KEYS.TEACHERS, - ]), - }); - - return newRow; - }; - const handleDuplicateAction = (duplicateData: TPureCourse) => { openCourseFormModal({ mode: E_MODAL_MODE.CREATE, @@ -238,14 +217,12 @@ export const CoursesDataTable = (): JSX.Element => { { field: E_COURSE_ENTITY_KEYS.NAME, headerName: 'Name', - editable: true, flex: 1, hideable: false, }, { field: E_COURSE_ENTITY_KEYS.ANNOTATION, headerName: 'Annotation', - editable: true, flex: 1, renderCell: ({ value, @@ -258,7 +235,6 @@ export const CoursesDataTable = (): JSX.Element => { field: E_COURSE_ENTITY_KEYS.CREDITS, type: 'number', headerName: 'Credits', - editable: true, }, { field: E_COURSE_ENTITY_KEYS.GUARANTOR, @@ -346,7 +322,6 @@ export const CoursesDataTable = (): JSX.Element => { sort: 'asc', }, ]} - processRowUpdate={handleRowUpdate} slots={{ loadingOverlay: LinearProgress, toolbar: () => ( From 2109712aef008dd74fe67db56d577da433093809 Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 18:50:34 +0200 Subject: [PATCH 03/13] refactor(courses): add courses mutations hook --- src/components/courses/data-table/index.tsx | 68 ++-------------- src/utils/hooks/useCourseMutations.ts | 88 +++++++++++++++++++++ 2 files changed, 94 insertions(+), 62 deletions(-) create mode 100644 src/utils/hooks/useCourseMutations.ts diff --git a/src/components/courses/data-table/index.tsx b/src/components/courses/data-table/index.tsx index b5438ba..fdcd51d 100644 --- a/src/components/courses/data-table/index.tsx +++ b/src/components/courses/data-table/index.tsx @@ -39,7 +39,7 @@ import { useNavigate } from 'react-router-dom'; import { useModal } from '../../../utils/hooks/useModal'; import { E_MODALS } from '../../../store/modals'; import { E_MODAL_MODE } from '../../../utils/modal/base-modal'; -import { toast } from 'react-hot-toast'; +import { useCourseMutations } from '../../../utils/hooks/useCourseMutations'; export const CoursesDataTable = (): JSX.Element => { const navigate = useNavigate(); @@ -55,68 +55,12 @@ export const CoursesDataTable = (): JSX.Element => { queryFn: CourseService.getCourses.bind(CourseService), }); - const createMutation = useMutation< - TPureCourse, - TApiError, - TCourseCreateMutationVariables - >({ - mutationFn: async ({ data: createData }: TCourseCreateMutationVariables) => - CourseService.createCourse(createData), - onSuccess: async () => { - await refetch(); - closeCourseFormModal(); - - toast.success('Course created successfully'); - }, - onError: async () => { - await refetch(); - - toast.error('Failed to create course'); - }, - }); - - const updateMutation = useMutation< - TPureCourse, - TApiError, - TCourseUpdateMutationVariables - >({ - mutationFn: async ({ - [E_COURSE_ENTITY_KEYS.ABBR]: abbr, - data: updateData, - }: TCourseUpdateMutationVariables) => - CourseService.updateCourse(abbr, updateData), - onSuccess: async () => { - await refetch(); - closeCourseFormModal(); - - toast.success('Course updated successfully!'); - }, - onError: async () => { - await refetch(); - - toast.error('Failed to update course'); - }, - }); - - const deleteMutation = useMutation< - void, - TApiError, - TCourseDeleteMutationVariables - >({ - mutationFn: async ({ - [E_COURSE_ENTITY_KEYS.ABBR]: abbr, - }: TCourseDeleteMutationVariables) => CourseService.deleteCourse(abbr), - onSuccess: async () => { - await refetch(); - - toast.success('Course deleted successfully'); - }, - onError: async () => { - await refetch(); - - toast.error('Failed to delete course'); + const { createMutation, updateMutation, deleteMutation } = useCourseMutations( + { + refetch, + closeCourseFormModal, }, - }); + ); const [rows, setRows] = useState>([]); const [rowSelection, setRowSelection] = useState>([]); diff --git a/src/utils/hooks/useCourseMutations.ts b/src/utils/hooks/useCourseMutations.ts new file mode 100644 index 0000000..f67dc83 --- /dev/null +++ b/src/utils/hooks/useCourseMutations.ts @@ -0,0 +1,88 @@ +import { useMutation } from '@tanstack/react-query'; +import { E_COURSE_ENTITY_KEYS, TPureCourse } from '../../api/courses/types'; +import { TApiError } from '../../api/base/types'; +import CourseService, { + TCourseCreateMutationVariables, + TCourseDeleteMutationVariables, + TCourseUpdateMutationVariables, +} from '../../api/courses/course.service'; +import { toast } from 'react-hot-toast'; + +export type TUseCourseMutationsParams = { + refetch(): Promise; + closeCourseFormModal(): void; +}; + +export const useCourseMutations = ({ + refetch, + closeCourseFormModal, +}: TUseCourseMutationsParams) => { + const createMutation = useMutation< + TPureCourse, + TApiError, + TCourseCreateMutationVariables + >({ + mutationFn: async ({ data: createData }: TCourseCreateMutationVariables) => + CourseService.createCourse(createData), + onSuccess: async () => { + await refetch(); + closeCourseFormModal(); + + toast.success('Course created successfully'); + }, + onError: async () => { + await refetch(); + + toast.error('Failed to create course'); + }, + }); + + const updateMutation = useMutation< + TPureCourse, + TApiError, + TCourseUpdateMutationVariables + >({ + mutationFn: async ({ + [E_COURSE_ENTITY_KEYS.ABBR]: abbr, + data: updateData, + }: TCourseUpdateMutationVariables) => + CourseService.updateCourse(abbr, updateData), + onSuccess: async () => { + await refetch(); + closeCourseFormModal(); + + toast.success('Course updated successfully!'); + }, + onError: async () => { + await refetch(); + + toast.error('Failed to update course'); + }, + }); + + const deleteMutation = useMutation< + void, + TApiError, + TCourseDeleteMutationVariables + >({ + mutationFn: async ({ + [E_COURSE_ENTITY_KEYS.ABBR]: abbr, + }: TCourseDeleteMutationVariables) => CourseService.deleteCourse(abbr), + onSuccess: async () => { + await refetch(); + + toast.success('Course deleted successfully'); + }, + onError: async () => { + await refetch(); + + toast.error('Failed to delete course'); + }, + }); + + return { + createMutation, + updateMutation, + deleteMutation, + }; +}; From bfe68bb2eb3b0d874d609f1cd3fcc636fadbe705 Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 18:50:55 +0200 Subject: [PATCH 04/13] feat(courses): add get certain course service method --- src/api/courses/course.service.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/api/courses/course.service.ts b/src/api/courses/course.service.ts index f360d15..3536eb2 100644 --- a/src/api/courses/course.service.ts +++ b/src/api/courses/course.service.ts @@ -37,6 +37,12 @@ export default class CourseService extends BaseService { return await Api.instance.get>(this.endpoint); } + public static async getCourse( + abbr: TPureCourse[E_COURSE_ENTITY_KEYS.ABBR], + ): Promise { + return await Api.instance.get(`${this.endpoint}/${abbr}`); + } + public static async createCourse( data: TCreateCourseData, ): Promise { From aefdaa60976c2e5c71bb4add4d7f7790122d75a9 Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 18:51:02 +0200 Subject: [PATCH 05/13] feat(courses): add use course hook --- src/utils/hooks/useCourse.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/utils/hooks/useCourse.ts diff --git a/src/utils/hooks/useCourse.ts b/src/utils/hooks/useCourse.ts new file mode 100644 index 0000000..606a41f --- /dev/null +++ b/src/utils/hooks/useCourse.ts @@ -0,0 +1,28 @@ +import { TApiError } from '../../api/base/types'; +import { + UseQueryResult, + useQuery, + UseQueryOptions, +} from '@tanstack/react-query'; +import { E_COURSE_ENTITY_KEYS, TPureCourse } from '../../api/courses/types'; +import CourseService from '../../api/courses/course.service'; + +export const useCourse = ( + abbr: TPureCourse[E_COURSE_ENTITY_KEYS.ABBR] | undefined, + options?: Omit< + UseQueryOptions< + TPureCourse | null, + TApiError, + TPureCourse | null, + Array + >, + 'initialData' | 'queryFn' | 'queryKey' + > & { initialData?(): undefined }, +): UseQueryResult => { + return useQuery( + ['course', abbr ?? null], + async (): Promise => + abbr ? CourseService.getCourse(abbr) : null, + options, + ); +}; From 7f8111b3a02a265c9b55afc22174d3eed4b74c8e Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 18:51:34 +0200 Subject: [PATCH 06/13] feat(router): filter invisible links in drawer --- src/App.tsx | 15 +++++++++------ src/utils/router/routes.tsx | 1 + 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index b56038e..59f2a27 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -47,12 +47,15 @@ const App = (): ReactElement => { const visibleRoutes = useMemo( (): Array => - filter(appRoutes, ({ roles, path }) => - user - ? userHasRoles(user, roles ?? null) && - path !== '/login' && - path !== '/register' - : path === '/login' || path === '/register', + filter( + filter(appRoutes, ({ roles, path }) => + user + ? userHasRoles(user, roles ?? null) && + path !== '/login' && + path !== '/register' + : path === '/login' || path === '/register', + ), + ({ showInNav }) => showInNav ?? true, ), [user], ); diff --git a/src/utils/router/routes.tsx b/src/utils/router/routes.tsx index 1899bd8..0d218b2 100644 --- a/src/utils/router/routes.tsx +++ b/src/utils/router/routes.tsx @@ -9,6 +9,7 @@ import { CoursesDataTable } from '../../components/courses/data-table'; export type TAppRoute = RouteObject & { path: string; label: string; + showInNav?: boolean; roles?: Array; }; From 19604543373d27bbba62f58a9ab3bbd6eb5bd949 Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 18:52:04 +0200 Subject: [PATCH 07/13] refactor(courses): remove unused imports from courses --- src/components/courses/data-table/index.tsx | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/components/courses/data-table/index.tsx b/src/components/courses/data-table/index.tsx index fdcd51d..8ab137d 100644 --- a/src/components/courses/data-table/index.tsx +++ b/src/components/courses/data-table/index.tsx @@ -1,8 +1,5 @@ -import { useMutation, useQuery } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; import CourseService, { - TCourseCreateMutationVariables, - TCourseDeleteMutationVariables, - TCourseUpdateMutationVariables, TCreateCourseData, TUpdateCourseData, } from '../../../api/courses/course.service'; @@ -16,16 +13,7 @@ import { } from '@mui/x-data-grid'; import { E_USER_ENTITY_KEYS } from '../../../api/user/types'; import { chipSelectColDef } from '../../data-grid/chip-select'; -import { - differenceWith, - forEach, - isEmpty, - isEqual, - omit, - pick, - toString, - unionBy, -} from 'lodash'; +import { forEach, isEmpty, pick, toString } from 'lodash'; import { GridRenderCellParams, GridValueFormatterParams, From e8ae9162fa6c46c7a0387e806637fa8b96a97ae2 Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 19:04:54 +0200 Subject: [PATCH 08/13] fix(courses): update teacher autocomplete not to close after selecting --- src/utils/modal/modals/course-form.modal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/utils/modal/modals/course-form.modal.tsx b/src/utils/modal/modals/course-form.modal.tsx index 235cb73..0af2017 100644 --- a/src/utils/modal/modals/course-form.modal.tsx +++ b/src/utils/modal/modals/course-form.modal.tsx @@ -280,6 +280,7 @@ const CourseFormModal = ({ /> )) } + disableCloseOnSelect filterOptions={filterFunc} options={usersData ?? []} getOptionLabel={(option) => From 0603045e68e328613730ad179a6a9dbe3091b723 Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 19:05:50 +0200 Subject: [PATCH 09/13] refactor(courses): move mutation and modal handlers to hooks --- src/components/courses/data-table/index.tsx | 61 ++------------- src/utils/hooks/useCourseModalHandlers.ts | 83 +++++++++++++++++++++ src/utils/hooks/useCourseMutations.ts | 22 +++++- 3 files changed, 111 insertions(+), 55 deletions(-) create mode 100644 src/utils/hooks/useCourseModalHandlers.ts diff --git a/src/components/courses/data-table/index.tsx b/src/components/courses/data-table/index.tsx index 8ab137d..3a15cb2 100644 --- a/src/components/courses/data-table/index.tsx +++ b/src/components/courses/data-table/index.tsx @@ -1,8 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import CourseService, { - TCreateCourseData, - TUpdateCourseData, -} from '../../../api/courses/course.service'; +import CourseService from '../../../api/courses/course.service'; import { JSX, useEffect, useState } from 'react'; import { E_COURSE_ENTITY_KEYS, TPureCourse } from '../../../api/courses/types'; import { @@ -13,7 +10,7 @@ import { } from '@mui/x-data-grid'; import { E_USER_ENTITY_KEYS } from '../../../api/user/types'; import { chipSelectColDef } from '../../data-grid/chip-select'; -import { forEach, isEmpty, pick, toString } from 'lodash'; +import { forEach, isEmpty, toString } from 'lodash'; import { GridRenderCellParams, GridValueFormatterParams, @@ -28,6 +25,7 @@ import { useModal } from '../../../utils/hooks/useModal'; import { E_MODALS } from '../../../store/modals'; import { E_MODAL_MODE } from '../../../utils/modal/base-modal'; import { useCourseMutations } from '../../../utils/hooks/useCourseMutations'; +import { useCourseModalHandlers } from '../../../utils/hooks/useCourseModalHandlers'; export const CoursesDataTable = (): JSX.Element => { const navigate = useNavigate(); @@ -50,6 +48,11 @@ export const CoursesDataTable = (): JSX.Element => { }, ); + const { handleCreateSuccess, handleUpdateSuccess } = useCourseModalHandlers({ + createMutation, + updateMutation, + }); + const [rows, setRows] = useState>([]); const [rowSelection, setRowSelection] = useState>([]); @@ -57,54 +60,6 @@ export const CoursesDataTable = (): JSX.Element => { if (data && !isFetching) setRows(data); }, [data, isFetching]); - const handleCreateSuccess = (createData: TCreateCourseData) => { - const pureData = pick(createData, [ - E_COURSE_ENTITY_KEYS.ABBR, - E_COURSE_ENTITY_KEYS.NAME, - E_COURSE_ENTITY_KEYS.CREDITS, - E_COURSE_ENTITY_KEYS.GUARANTOR, - ]); - createMutation.mutate({ - data: { - ...pureData, - ...(createData[E_COURSE_ENTITY_KEYS.ANNOTATION] && { - [E_COURSE_ENTITY_KEYS.ANNOTATION]: - createData[E_COURSE_ENTITY_KEYS.ANNOTATION], - }), - ...(createData[E_COURSE_ENTITY_KEYS.TEACHERS] && { - [E_COURSE_ENTITY_KEYS.TEACHERS]: - createData[E_COURSE_ENTITY_KEYS.TEACHERS], - }), - }, - }); - }; - - const handleUpdateSuccess = ( - abbr: TPureCourse[E_COURSE_ENTITY_KEYS.ABBR], - updateData: TUpdateCourseData, - ) => { - const pureData = pick(updateData, [ - E_COURSE_ENTITY_KEYS.ABBR, - E_COURSE_ENTITY_KEYS.NAME, - E_COURSE_ENTITY_KEYS.CREDITS, - E_COURSE_ENTITY_KEYS.GUARANTOR, - ]); - updateMutation.mutate({ - abbr, - data: { - ...pureData, - ...(updateData[E_COURSE_ENTITY_KEYS.ANNOTATION] && { - [E_COURSE_ENTITY_KEYS.ANNOTATION]: - updateData[E_COURSE_ENTITY_KEYS.ANNOTATION], - }), - ...(updateData[E_COURSE_ENTITY_KEYS.TEACHERS] && { - [E_COURSE_ENTITY_KEYS.TEACHERS]: - updateData[E_COURSE_ENTITY_KEYS.TEACHERS], - }), - }, - }); - }; - const handleDeleteSelected = () => { forEach(rowSelection, (id) => { deleteMutation.mutate({ diff --git a/src/utils/hooks/useCourseModalHandlers.ts b/src/utils/hooks/useCourseModalHandlers.ts new file mode 100644 index 0000000..f8c7fb8 --- /dev/null +++ b/src/utils/hooks/useCourseModalHandlers.ts @@ -0,0 +1,83 @@ +import { TUseCourseMutations } from './useCourseMutations'; +import { + TCreateCourseData, + TUpdateCourseData, +} from '../../api/courses/course.service'; +import { pick } from 'lodash'; +import { E_COURSE_ENTITY_KEYS, TPureCourse } from '../../api/courses/types'; + +export type TUseCourseModalHandlersParams = Partial< + Omit +>; + +export type TUseCourseModalHandlers = { + handleCreateSuccess(createData: TCreateCourseData): void; + handleUpdateSuccess( + abbr: TPureCourse[E_COURSE_ENTITY_KEYS.ABBR], + updateData: TUpdateCourseData, + ): void; +}; + +export const useCourseModalHandlers = ({ + createMutation, + updateMutation, +}: TUseCourseModalHandlersParams): TUseCourseModalHandlers => { + const handleCreateSuccess = (createData: TCreateCourseData) => { + if (!createMutation) return; + + const pureData = pick(createData, [ + E_COURSE_ENTITY_KEYS.ABBR, + E_COURSE_ENTITY_KEYS.NAME, + E_COURSE_ENTITY_KEYS.CREDITS, + E_COURSE_ENTITY_KEYS.GUARANTOR, + ]); + + createMutation.mutate({ + data: { + ...pureData, + ...(createData[E_COURSE_ENTITY_KEYS.ANNOTATION] && { + [E_COURSE_ENTITY_KEYS.ANNOTATION]: + createData[E_COURSE_ENTITY_KEYS.ANNOTATION], + }), + ...(createData[E_COURSE_ENTITY_KEYS.TEACHERS] && { + [E_COURSE_ENTITY_KEYS.TEACHERS]: + createData[E_COURSE_ENTITY_KEYS.TEACHERS], + }), + }, + }); + }; + + const handleUpdateSuccess = ( + abbr: TPureCourse[E_COURSE_ENTITY_KEYS.ABBR], + updateData: TUpdateCourseData, + ) => { + if (!updateMutation) return; + + const pureData = pick(updateData, [ + E_COURSE_ENTITY_KEYS.ABBR, + E_COURSE_ENTITY_KEYS.NAME, + E_COURSE_ENTITY_KEYS.CREDITS, + E_COURSE_ENTITY_KEYS.GUARANTOR, + ]); + + updateMutation.mutate({ + abbr, + data: { + ...pureData, + ...(updateData[E_COURSE_ENTITY_KEYS.ANNOTATION] && { + [E_COURSE_ENTITY_KEYS.ANNOTATION]: + updateData[E_COURSE_ENTITY_KEYS.ANNOTATION], + }), + ...(updateData[E_COURSE_ENTITY_KEYS.TEACHERS] && { + [E_COURSE_ENTITY_KEYS.TEACHERS]: + updateData[E_COURSE_ENTITY_KEYS.TEACHERS], + }), + }, + }); + }; + + return { + handleCreateSuccess, + handleUpdateSuccess, + }; +}; diff --git a/src/utils/hooks/useCourseMutations.ts b/src/utils/hooks/useCourseMutations.ts index f67dc83..fcc17f7 100644 --- a/src/utils/hooks/useCourseMutations.ts +++ b/src/utils/hooks/useCourseMutations.ts @@ -1,4 +1,4 @@ -import { useMutation } from '@tanstack/react-query'; +import { useMutation, UseMutationResult } from '@tanstack/react-query'; import { E_COURSE_ENTITY_KEYS, TPureCourse } from '../../api/courses/types'; import { TApiError } from '../../api/base/types'; import CourseService, { @@ -13,10 +13,28 @@ export type TUseCourseMutationsParams = { closeCourseFormModal(): void; }; +export type TUseCourseMutations = { + createMutation: UseMutationResult< + TPureCourse, + TApiError, + TCourseCreateMutationVariables + >; + updateMutation: UseMutationResult< + TPureCourse, + TApiError, + TCourseUpdateMutationVariables + >; + deleteMutation: UseMutationResult< + void, + TApiError, + TCourseDeleteMutationVariables + >; +}; + export const useCourseMutations = ({ refetch, closeCourseFormModal, -}: TUseCourseMutationsParams) => { +}: TUseCourseMutationsParams): TUseCourseMutations => { const createMutation = useMutation< TPureCourse, TApiError, From c295f780cdd1a2d8e3fc9e2fc64cb02b7799ea7e Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 19:34:28 +0200 Subject: [PATCH 10/13] feat(auth): add current user hook --- src/utils/hooks/useCurrentUser.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 src/utils/hooks/useCurrentUser.ts diff --git a/src/utils/hooks/useCurrentUser.ts b/src/utils/hooks/useCurrentUser.ts new file mode 100644 index 0000000..854981e --- /dev/null +++ b/src/utils/hooks/useCurrentUser.ts @@ -0,0 +1,16 @@ +import { useLocalStorage } from 'usehooks-ts'; +import { E_LOCAL_STORAGE_KEYS } from '../local-storage'; +import { TApiUserWithRoles } from '../../api/user/types'; + +export const useCurrentUser = () => { + const [currentUser, setCurrentUser] = + useLocalStorage( + E_LOCAL_STORAGE_KEYS.USER_INFO, + null, + ); + + return { + currentUser, + setCurrentUser, + }; +}; From cc3c86c91f241416d104c60e068d4c5be82eba4e Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 19:34:37 +0200 Subject: [PATCH 11/13] feat(courses): add course permissions hook --- src/utils/hooks/useCoursePermissions.ts | 78 +++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/utils/hooks/useCoursePermissions.ts diff --git a/src/utils/hooks/useCoursePermissions.ts b/src/utils/hooks/useCoursePermissions.ts new file mode 100644 index 0000000..ad2e04b --- /dev/null +++ b/src/utils/hooks/useCoursePermissions.ts @@ -0,0 +1,78 @@ +import { useCurrentUser } from './useCurrentUser'; +import { + E_ROLE, + E_ROLE_ENTITY_KEYS, + E_USER_ENTITY_KEYS, +} from '../../api/user/types'; +import { map, some } from 'lodash'; +import { E_COURSE_ENTITY_KEYS, TPureCourse } from '../../api/courses/types'; + +export const courseManageRoles = [ + E_ROLE.ADMIN, + E_ROLE.GUARANTOR, + E_ROLE.TEACHER, +] as const; + +export type TUseCoursePermissions = { + canCreateCourse: boolean; + canUpdateCourse: boolean; + canDeleteCourse: boolean; +}; + +export const useCoursePermissions = ( + course?: TPureCourse, +): TUseCoursePermissions => { + const { currentUser } = useCurrentUser(); + + if (!currentUser) { + return { + canCreateCourse: false, + canUpdateCourse: false, + canDeleteCourse: false, + }; + } + + const userRoles = map( + currentUser[E_USER_ENTITY_KEYS.ROLES], + E_ROLE_ENTITY_KEYS.NAME, + ); + + const isAdmin = userRoles.includes(E_ROLE.ADMIN); + const isGuarantor = userRoles.includes(E_ROLE.GUARANTOR); + + if (!course) { + return { + canCreateCourse: isAdmin || isGuarantor, + canUpdateCourse: isAdmin || isGuarantor, + canDeleteCourse: isAdmin || isGuarantor, + }; + } + + const canCreateCourse = some(courseManageRoles, (role) => + userRoles.includes(role), + ); + + let canUpdateCourse = false; + + if (isGuarantor) + canUpdateCourse = + course[E_COURSE_ENTITY_KEYS.GUARANTOR][E_USER_ENTITY_KEYS.ID] === + currentUser[E_USER_ENTITY_KEYS.ID]; + + if (isAdmin) canUpdateCourse = true; + + let canDeleteCourse = false; + + if (isGuarantor) + canDeleteCourse = + course[E_COURSE_ENTITY_KEYS.GUARANTOR][E_USER_ENTITY_KEYS.ID] === + currentUser[E_USER_ENTITY_KEYS.ID]; + + if (isAdmin) canDeleteCourse = true; + + return { + canCreateCourse, + canUpdateCourse, + canDeleteCourse, + }; +}; From e813ba730b5a7a781b3929a911e3f0ea0f93575a Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 19:35:04 +0200 Subject: [PATCH 12/13] feat(courses): update courses data table layout based on permissions --- src/components/courses/data-table/index.tsx | 59 +++++++++------ src/components/courses/data-table/toolbar.tsx | 73 +++++++++++-------- 2 files changed, 80 insertions(+), 52 deletions(-) diff --git a/src/components/courses/data-table/index.tsx b/src/components/courses/data-table/index.tsx index 3a15cb2..dd2d725 100644 --- a/src/components/courses/data-table/index.tsx +++ b/src/components/courses/data-table/index.tsx @@ -26,6 +26,7 @@ import { E_MODALS } from '../../../store/modals'; import { E_MODAL_MODE } from '../../../utils/modal/base-modal'; import { useCourseMutations } from '../../../utils/hooks/useCourseMutations'; import { useCourseModalHandlers } from '../../../utils/hooks/useCourseModalHandlers'; +import { useCoursePermissions } from '../../../utils/hooks/useCoursePermissions'; export const CoursesDataTable = (): JSX.Element => { const navigate = useNavigate(); @@ -41,6 +42,9 @@ export const CoursesDataTable = (): JSX.Element => { queryFn: CourseService.getCourses.bind(CourseService), }); + const { canCreateCourse, canUpdateCourse, canDeleteCourse } = + useCoursePermissions(); + const { createMutation, updateMutation, deleteMutation } = useCourseMutations( { refetch, @@ -158,26 +162,38 @@ export const CoursesDataTable = (): JSX.Element => { navigate(`/courses/${params.row[E_COURSE_ENTITY_KEYS.ABBR]}`) } />, - handleDuplicateAction(params.row)} - />, - handleEditAction(params.row)} - />, - - handleDeleteAction(params.row[E_COURSE_ENTITY_KEYS.ABBR]) - } - />, + ...(canCreateCourse + ? [ + handleDuplicateAction(params.row)} + />, + ] + : []), + ...(canUpdateCourse + ? [ + handleEditAction(params.row)} + />, + ] + : []), + ...(canDeleteCourse + ? [ + + handleDeleteAction(params.row[E_COURSE_ENTITY_KEYS.ABBR]) + } + />, + ] + : []), ], }, ]; @@ -197,9 +213,8 @@ export const CoursesDataTable = (): JSX.Element => { row[E_COURSE_ENTITY_KEYS.ABBR]} rowSelectionModel={rowSelection} onRowSelectionModelChange={handleRowSelection} diff --git a/src/components/courses/data-table/toolbar.tsx b/src/components/courses/data-table/toolbar.tsx index 519249a..1683c86 100644 --- a/src/components/courses/data-table/toolbar.tsx +++ b/src/components/courses/data-table/toolbar.tsx @@ -6,6 +6,7 @@ import { Add, Delete } from '@mui/icons-material'; import { GridRowId } from '@mui/x-data-grid'; import { E_MODAL_MODE } from '../../../utils/modal/base-modal'; import { TCreateCourseData } from '../../../api/courses/course.service'; +import { useCoursePermissions } from '../../../utils/hooks/useCoursePermissions'; export type TCourseDataTableToolbarProps = { rowSelection: Array; @@ -19,33 +20,45 @@ export const CourseDataTableToolbar = ({ openCreateModal, handleCreateSuccess, handleDeleteSelected, -}: TCourseDataTableToolbarProps): JSX.Element => ( - } - onClick={() => - openCreateModal({ - mode: E_MODAL_MODE.CREATE, - onSuccess: handleCreateSuccess, - }) - } - > - Add new course - , - ]} - endButtons={[ - , - ]} - /> -); +}: TCourseDataTableToolbarProps): JSX.Element => { + const { canCreateCourse, canDeleteCourse } = useCoursePermissions(); + + return ( + } + onClick={() => + openCreateModal({ + mode: E_MODAL_MODE.CREATE, + onSuccess: handleCreateSuccess, + }) + } + > + Add new course + , + ] + : []), + ]} + endButtons={[ + ...(canDeleteCourse + ? [ + , + ] + : []), + ]} + /> + ); +}; From 6662eaaf7b3e5302ea4784bc7d8d6005e7926dcc Mon Sep 17 00:00:00 2001 From: NickSettler Date: Sat, 21 Oct 2023 19:35:17 +0200 Subject: [PATCH 13/13] feat(courses): add course info page --- src/components/courses/course-info/index.tsx | 252 +++++++++++++++++++ src/utils/router/routes.tsx | 11 +- 2 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 src/components/courses/course-info/index.tsx diff --git a/src/components/courses/course-info/index.tsx b/src/components/courses/course-info/index.tsx new file mode 100644 index 0000000..1262f33 --- /dev/null +++ b/src/components/courses/course-info/index.tsx @@ -0,0 +1,252 @@ +import React, { JSX, useMemo } from 'react'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import { E_COURSE_ENTITY_KEYS } from '../../../api/courses/types'; +import { isUndefined } from 'lodash'; +import { useCourse } from '../../../utils/hooks/useCourse'; +import { + IconButton, + ListItem, + Paper, + Skeleton, + Stack, + Tooltip, + Typography, +} from '@mui/material'; +import Button from '@mui/material/Button'; +import { ArrowBack, Delete, Edit } from '@mui/icons-material'; +import { E_USER_ENTITY_KEYS } from '../../../api/user/types'; +import Grid from '@mui/material/Grid'; +import Container from '@mui/material/Container'; +import List from '@mui/material/List'; +import ListItemText from '@mui/material/ListItemText'; +import { useCourseMutations } from '../../../utils/hooks/useCourseMutations'; +import { useModal } from '../../../utils/hooks/useModal'; +import { E_MODALS } from '../../../store/modals'; +import { E_MODAL_MODE } from '../../../utils/modal/base-modal'; +import { useCourseModalHandlers } from '../../../utils/hooks/useCourseModalHandlers'; +import { toast } from 'react-hot-toast'; +import { useCoursePermissions } from '../../../utils/hooks/useCoursePermissions'; + +export const CourseInfo = (): JSX.Element => { + const { abbr } = useParams<'abbr'>(); + const navigate = useNavigate(); + + if (isUndefined(abbr)) navigate('/courses'); + + const { onOpen: openCourseFormModal, onClose: closeCourseFormModal } = + useModal(E_MODALS.COURSE_FORM); + + const { data, isLoading, error, refetch } = useCourse(abbr); + + const { canUpdateCourse, canDeleteCourse } = useCoursePermissions( + data ?? undefined, + ); + + const { updateMutation, deleteMutation } = useCourseMutations({ + refetch, + closeCourseFormModal, + }); + + const { handleUpdateSuccess } = useCourseModalHandlers({ + updateMutation, + }); + + const handleEditClick = () => { + if (!data) return; + + openCourseFormModal({ + abbr: data[E_COURSE_ENTITY_KEYS.ABBR], + mode: E_MODAL_MODE.UPDATE, + initialData: data, + onSuccess: handleUpdateSuccess, + }); + }; + + const handleDeleteClick = () => { + if (!data || !abbr) return; + + deleteMutation.mutate( + { + [E_COURSE_ENTITY_KEYS.ABBR]: abbr, + }, + { + onSuccess: async () => { + navigate(-1); + + toast.success('Course deleted successfully'); + }, + onError: async () => { + await refetch(); + + toast.error('Failed to delete course'); + }, + }, + ); + }; + + const title = useMemo( + () => + data + ? `${data[E_COURSE_ENTITY_KEYS.ABBR]} - ${ + data[E_COURSE_ENTITY_KEYS.NAME] + }` + : '', + [data], + ); + + const guarantor = useMemo(() => { + if (!data) return ''; + + const g = data[E_COURSE_ENTITY_KEYS.GUARANTOR]; + + return `${g[E_USER_ENTITY_KEYS.FIRST_NAME]} ${ + g[E_USER_ENTITY_KEYS.LAST_NAME] + } (${g[E_USER_ENTITY_KEYS.USERNAME]})`; + }, [data]); + + const credits = useMemo( + () => (data ? data[E_COURSE_ENTITY_KEYS.CREDITS] : ''), + [data], + ); + + const annotation = useMemo( + () => (data ? data[E_COURSE_ENTITY_KEYS.ANNOTATION] : ''), + [data], + ); + + const teachers = useMemo( + () => (data ? data[E_COURSE_ENTITY_KEYS.TEACHERS] : []), + [data], + ); + + if (error && error.statusCode === 404) return ; + + return ( + + + + + navigate(-1)}> + + + + {isLoading ? ( + + + + ) : ( + + {title} + + )} + + {(canUpdateCourse || canDeleteCourse) && ( + + {canUpdateCourse && ( + + )} + {canDeleteCourse && ( + + )} + + )} + + + + + + Guarantor + {isLoading ? ( + + + Admin + + + ) : ( + + {guarantor} + + )} + + + Credits + {isLoading ? ( + + + 0 + + + ) : ( + + {credits} + + )} + + + Annotation + {isLoading ? ( + + + None + + + ) : ( + + {annotation} + + )} + + + + Teachers + + + {teachers.map((teacher) => ( + + + + ))} + + + + + + + ); +}; diff --git a/src/utils/router/routes.tsx b/src/utils/router/routes.tsx index 0d218b2..935efba 100644 --- a/src/utils/router/routes.tsx +++ b/src/utils/router/routes.tsx @@ -5,6 +5,8 @@ import SignUpPage from '../../pages/register'; import { UsersDataTable } from '../../components/users/data-table'; import { E_ROLE } from '../../api/user/types'; import { CoursesDataTable } from '../../components/courses/data-table'; +import { CourseInfo } from '../../components/courses/course-info'; +import { courseManageRoles } from '../hooks/useCoursePermissions'; export type TAppRoute = RouteObject & { path: string; @@ -38,7 +40,14 @@ export const appRoutes: Array = [ { path: '/courses', label: 'Courses', - roles: [E_ROLE.ADMIN, E_ROLE.GUARANTOR, E_ROLE.TEACHER], + roles: [...courseManageRoles, E_ROLE.SCHEDULER, E_ROLE.STUDENT], element: , }, + { + path: '/courses/:abbr', + label: 'Course info', + roles: [...courseManageRoles, E_ROLE.SCHEDULER, E_ROLE.STUDENT], + showInNav: false, + element: , + }, ];