From aef23808d61ceca0f7ab4c44a951ba29eb100e31 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 25 Oct 2024 00:34:26 +0200 Subject: [PATCH 01/16] adds API call. Cleaned up recruiment position overview page --- .../RecruitmentPositionOverviewPage.tsx | 145 +++++++----------- frontend/src/api.ts | 6 +- 2 files changed, 62 insertions(+), 89 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index 9d90f823c..032e7079c 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -4,7 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; import { Button, RecruitmentApplicantsStatus } from '~/Components'; import { Text } from '~/Components/Text/Text'; -import { getRecruitmentApplicationsForGang, updateRecruitmentApplicationStateForPosition } from '~/api'; +import { getRecruitmentApplicationsForRecruitmentPosition, updateRecruitmentApplicationStateForPosition } from '~/api'; import type { RecruitmentApplicationDto, RecruitmentApplicationStateDto } from '~/dto'; import { useTitle } from '~/hooks'; import { STATUS } from '~/http_status_codes'; @@ -16,6 +16,49 @@ import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; import styles from './RecruitmentPositionOverviewPage.module.scss'; import { ProcessedApplicants } from './components'; +const withdrawnFilter = (data: RecruitmentApplicationDto[], positionId: string) => { + return data.filter( + (recruitmentApplicant) => + recruitmentApplicant.withdrawn && recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), + ); +}; + +const applicantsFilter = (data: RecruitmentApplicationDto[], positionId: string) => { + return data.filter( + (recruitmentApplicant) => + !recruitmentApplicant.withdrawn && + recruitmentApplicant.recruiter_status === 0 && + recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), + ); +}; + +const hardToGetFilter = (data: RecruitmentApplicationDto[], positionId: string) => { + return data.filter( + (recruitmentApplicant) => + !recruitmentApplicant.withdrawn && + recruitmentApplicant.recruiter_status === 2 && + recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), + ); +}; + +const acceptedApplicantsFilter = (data: RecruitmentApplicationDto[], positionId: string) => { + return data.filter( + (recruitmentApplicant) => + !recruitmentApplicant.withdrawn && + recruitmentApplicant.recruiter_status === 3 && + recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), + ); +}; + +const rejectedApplicantsFilter = (data: RecruitmentApplicationDto[], positionId: string) => { + return data.filter( + (recruitmentApplicant) => + !recruitmentApplicant.withdrawn && + recruitmentApplicant.recruiter_status === 3 && + recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), + ); +}; + export function RecruitmentPositionOverviewPage() { const navigate = useNavigate(); const { recruitmentId, gangId, positionId } = useParams(); @@ -25,55 +68,19 @@ export function RecruitmentPositionOverviewPage() { const [acceptedApplicants, setAcceptedApplicants] = useState([]); const [hardtogetApplicants, setHardtogetApplicants] = useState([]); //Applicants that have been offered a position, but did not accept it - const [recruiterStatuses, setRecruiterStatuses] = useState<[][]>([]); - const [showSpinner, setShowSpinner] = useState(true); const { t } = useTranslation(); const load = useCallback(() => { - if (!recruitmentId || !gangId || !positionId) { + if (!positionId) { return; } - getRecruitmentApplicationsForGang(gangId, recruitmentId) - .then((data) => { - setRecruitmentApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 0 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setWithdrawnApplicants( - data.data.filter( - (recruitmentApplicant) => - recruitmentApplicant.withdrawn && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setHardtogetApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 2 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setRejectedApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 3 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setAcceptedApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 1 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); + getRecruitmentApplicationsForRecruitmentPosition(positionId) + .then((response) => { + setRecruitmentApplicants(applicantsFilter(response.data, positionId)); + setWithdrawnApplicants(withdrawnFilter(response.data, positionId)); + setHardtogetApplicants(hardToGetFilter(response.data, positionId)); + setRejectedApplicants(rejectedApplicantsFilter(response.data, positionId)); + setAcceptedApplicants(acceptedApplicantsFilter(response.data, positionId)); setShowSpinner(false); }) .catch((data) => { @@ -82,7 +89,7 @@ export function RecruitmentPositionOverviewPage() { } toast.error(t(KEY.common_something_went_wrong)); }); - }, [recruitmentId, gangId, positionId, navigate, t]); + }, [positionId, navigate]); useEffect(() => { load(); @@ -91,46 +98,12 @@ export function RecruitmentPositionOverviewPage() { const updateApplicationState = (id: string, data: RecruitmentApplicationStateDto) => { positionId && updateRecruitmentApplicationStateForPosition(id, data) - .then((data) => { - setRecruitmentApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 0 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setWithdrawnApplicants( - data.data.filter( - (recruitmentApplicant) => - recruitmentApplicant.withdrawn && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setHardtogetApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 2 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setRejectedApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 3 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); - setAcceptedApplicants( - data.data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 1 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ), - ); + .then((response) => { + setRecruitmentApplicants(applicantsFilter(response.data, positionId)); + setWithdrawnApplicants(withdrawnFilter(response.data, positionId)); + setHardtogetApplicants(hardToGetFilter(response.data, positionId)); + setRejectedApplicants(rejectedApplicantsFilter(response.data, positionId)); + setAcceptedApplicants(acceptedApplicantsFilter(response.data, positionId)); setShowSpinner(false); }) .catch((data) => { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 4b9d5872c..c4b4e7f33 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -809,13 +809,13 @@ export async function downloadCSVGangRecruitment(recruitmentId: string, gangId: } export async function getRecruitmentApplicationsForRecruitmentPosition( - recruitmentPositionId: string, + positionId: string, ): Promise> { const url = BACKEND_DOMAIN + reverse({ - pattern: ROUTES.backend.samfundet__recruitment_applications_for_gang_detail, - urlParams: { pk: recruitmentPositionId }, + pattern: ROUTES.backend.samfundet__recruitment_applications_for_position_detail, + urlParams: { pk: positionId }, }); return await axios.get(url, { withCredentials: true }); } From caf477290585ca0cc6514cb9c352427daaa9cde8 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 25 Oct 2024 00:45:46 +0200 Subject: [PATCH 02/16] updates filter and modifeis view --- backend/samfundet/views.py | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 5d1d4aa9a..88a98cff9 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -1072,22 +1072,35 @@ class RecruitmentApplicationForRecruitmentPositionView(ModelViewSet): serializer_class = RecruitmentApplicationForGangSerializer queryset = RecruitmentApplication.objects.all() - # TODO: User should only be able to edit the fields that are allowed - - def retrieve(self, request: Request, pk: int) -> Response: - """Returns a list of all the recruitments for the specified gang.""" + def retrieve(self, request: Request, pk: int, *args: Any, **kwargs: Any) -> Response: # noqa: C901 + """ + Retrieve filtered applications for a specific recruitment position. + If no filter_type is provided, returns all applications. + Query Parameters: + - filter_type: string, one of ['unprocessed', 'withdrawn', 'hardtoget', 'accepted', 'rejected'] + """ position = get_object_or_404(RecruitmentPosition, id=pk) - - applications = RecruitmentApplication.objects.filter( - recruitment_position=position, - ) - - # check permissions for each application - applications = get_objects_for_user(user=request.user, perms=['view_recruitmentapplication'], klass=applications) + applications = self.get_queryset().filter(recruitment_position=position) + + filter_type = request.query_params.get('filter_type') + + if filter_type: + if filter_type == 'unprocessed': + applications = applications.filter(withdrawn=False, recruiter_status=RecruitmentStatusChoices.NOT_SET) + elif filter_type == 'withdrawn': + applications = applications.filter(withdrawn=True) + elif filter_type == 'hardtoget': + applications = applications.filter(withdrawn=False, recruiter_status=RecruitmentStatusChoices.CALLED_AND_REJECTED) + elif filter_type == 'accepted': + applications = applications.filter(withdrawn=False, recruiter_status=RecruitmentStatusChoices.CALLED_AND_ACCEPTED) + elif filter_type == 'rejected': + applications = applications.filter(withdrawn=False, recruiter_status=RecruitmentStatusChoices.REJECTION) + else: + return Response({'error': 'Invalid filter_type parameter'}, status=status.HTTP_400_BAD_REQUEST) serializer = self.get_serializer(applications, many=True) - return Response(serializer.data) + return Response(data=serializer.data) class ActiveRecruitmentPositionsView(ListAPIView): From 9f52d0b93ecd8c0672b6022b386a0436c29bd6cf Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 25 Oct 2024 00:46:04 +0200 Subject: [PATCH 03/16] implements new API call --- .../RecruitmentPositionOverviewPage.tsx | 30 ++++++++++++------- frontend/src/api.ts | 10 ++++++- 2 files changed, 29 insertions(+), 11 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index 032e7079c..ca2ae437d 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -74,22 +74,32 @@ export function RecruitmentPositionOverviewPage() { if (!positionId) { return; } - getRecruitmentApplicationsForRecruitmentPosition(positionId) - .then((response) => { - setRecruitmentApplicants(applicantsFilter(response.data, positionId)); - setWithdrawnApplicants(withdrawnFilter(response.data, positionId)); - setHardtogetApplicants(hardToGetFilter(response.data, positionId)); - setRejectedApplicants(rejectedApplicantsFilter(response.data, positionId)); - setAcceptedApplicants(acceptedApplicantsFilter(response.data, positionId)); + + // Create an array of promises for all filter types + const promises = [ + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'unprocessed'), + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'withdrawn'), + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'hardtoget'), + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'rejected'), + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'accepted'), + ]; + + Promise.all(promises) + .then(([unprocessed, withdrawn, hardToGet, rejected, accepted]) => { + setRecruitmentApplicants(unprocessed.data); + setWithdrawnApplicants(withdrawn.data); + setHardtogetApplicants(hardToGet.data); + setRejectedApplicants(rejected.data); + setAcceptedApplicants(accepted.data); setShowSpinner(false); }) - .catch((data) => { - if (data.status === STATUS.HTTP_404_NOT_FOUND) { + .catch((error) => { + if (error.response?.status === STATUS.HTTP_404_NOT_FOUND) { navigate(ROUTES.frontend.not_found, { replace: true }); } toast.error(t(KEY.common_something_went_wrong)); }); - }, [positionId, navigate]); + }, [positionId, navigate, t]); useEffect(() => { load(); diff --git a/frontend/src/api.ts b/frontend/src/api.ts index c4b4e7f33..47b1da4b0 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -810,6 +810,7 @@ export async function downloadCSVGangRecruitment(recruitmentId: string, gangId: export async function getRecruitmentApplicationsForRecruitmentPosition( positionId: string, + filterType?: string, ): Promise> { const url = BACKEND_DOMAIN + @@ -817,7 +818,14 @@ export async function getRecruitmentApplicationsForRecruitmentPosition( pattern: ROUTES.backend.samfundet__recruitment_applications_for_position_detail, urlParams: { pk: positionId }, }); - return await axios.get(url, { withCredentials: true }); + + // Add filter_type as a query parameter if it exists + const params = filterType ? { filter_type: filterType } : {}; + + return await axios.get(url, { + withCredentials: true, + params: params, + }); } export async function putRecruitmentApplicationForGang( From 55dd1ed4de0fbaf0029767eecadf72e8d3c6eeeb Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Fri, 25 Oct 2024 00:56:29 +0200 Subject: [PATCH 04/16] starts to implement react query to recruitment position overview page --- .../RecruitmentPositionOverviewPage.tsx | 198 ++++-------------- frontend/src/api.ts | 7 +- 2 files changed, 49 insertions(+), 156 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index ca2ae437d..d5948d5b3 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -1,13 +1,12 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; import { Button, RecruitmentApplicantsStatus } from '~/Components'; import { Text } from '~/Components/Text/Text'; import { getRecruitmentApplicationsForRecruitmentPosition, updateRecruitmentApplicationStateForPosition } from '~/api'; -import type { RecruitmentApplicationDto, RecruitmentApplicationStateDto } from '~/dto'; +import type { RecruitmentApplicationStateDto } from '~/dto'; import { useTitle } from '~/hooks'; -import { STATUS } from '~/http_status_codes'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; @@ -16,110 +15,46 @@ import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; import styles from './RecruitmentPositionOverviewPage.module.scss'; import { ProcessedApplicants } from './components'; -const withdrawnFilter = (data: RecruitmentApplicationDto[], positionId: string) => { - return data.filter( - (recruitmentApplicant) => - recruitmentApplicant.withdrawn && recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ); -}; - -const applicantsFilter = (data: RecruitmentApplicationDto[], positionId: string) => { - return data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 0 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ); -}; - -const hardToGetFilter = (data: RecruitmentApplicationDto[], positionId: string) => { - return data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 2 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ); -}; - -const acceptedApplicantsFilter = (data: RecruitmentApplicationDto[], positionId: string) => { - return data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 3 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ); -}; - -const rejectedApplicantsFilter = (data: RecruitmentApplicationDto[], positionId: string) => { - return data.filter( - (recruitmentApplicant) => - !recruitmentApplicant.withdrawn && - recruitmentApplicant.recruiter_status === 3 && - recruitmentApplicant.recruitment_position?.id === Number.parseInt(positionId), - ); +const queryKeys = { + applications: (positionId: string, filterType: string) => ['applications', positionId, filterType] as const, }; export function RecruitmentPositionOverviewPage() { const navigate = useNavigate(); const { recruitmentId, gangId, positionId } = useParams(); - const [recruitmentApplicants, setRecruitmentApplicants] = useState([]); - const [withdrawnApplicants, setWithdrawnApplicants] = useState([]); - const [rejectedApplicants, setRejectedApplicants] = useState([]); - const [acceptedApplicants, setAcceptedApplicants] = useState([]); - const [hardtogetApplicants, setHardtogetApplicants] = useState([]); //Applicants that have been offered a position, but did not accept it - - const [showSpinner, setShowSpinner] = useState(true); + const queryClient = useQueryClient(); const { t } = useTranslation(); - const load = useCallback(() => { - if (!positionId) { - return; - } - // Create an array of promises for all filter types - const promises = [ - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'unprocessed'), - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'withdrawn'), - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'hardtoget'), - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'rejected'), - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'accepted'), - ]; + // Store the query keys separately so we can use them for invalidation + const filterTypes = ['unprocessed', 'withdrawn', 'hardtoget', 'rejected', 'accepted'] as const; + const allQueryKeys = filterTypes.map((filterType) => queryKeys.applications(positionId!, filterType)); - Promise.all(promises) - .then(([unprocessed, withdrawn, hardToGet, rejected, accepted]) => { - setRecruitmentApplicants(unprocessed.data); - setWithdrawnApplicants(withdrawn.data); - setHardtogetApplicants(hardToGet.data); - setRejectedApplicants(rejected.data); - setAcceptedApplicants(accepted.data); - setShowSpinner(false); - }) - .catch((error) => { - if (error.response?.status === STATUS.HTTP_404_NOT_FOUND) { - navigate(ROUTES.frontend.not_found, { replace: true }); - } - toast.error(t(KEY.common_something_went_wrong)); - }); - }, [positionId, navigate, t]); + const results = useQueries({ + queries: filterTypes.map((filterType) => ({ + queryKey: queryKeys.applications(positionId!, filterType), + queryFn: () => getRecruitmentApplicationsForRecruitmentPosition(positionId!, filterType), + enabled: !!positionId, + })), + }); - useEffect(() => { - load(); - }, [load]); + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: RecruitmentApplicationStateDto }) => + updateRecruitmentApplicationStateForPosition(id, data), + onSuccess: async () => { + // Use the stored query keys for invalidation + for (const queryKey of allQueryKeys) { + await queryClient.invalidateQueries({ queryKey }); + } + }, + onError: () => { + toast.error(t(KEY.common_something_went_wrong)); + }, + }); - const updateApplicationState = (id: string, data: RecruitmentApplicationStateDto) => { - positionId && - updateRecruitmentApplicationStateForPosition(id, data) - .then((response) => { - setRecruitmentApplicants(applicantsFilter(response.data, positionId)); - setWithdrawnApplicants(withdrawnFilter(response.data, positionId)); - setHardtogetApplicants(hardToGetFilter(response.data, positionId)); - setRejectedApplicants(rejectedApplicantsFilter(response.data, positionId)); - setAcceptedApplicants(acceptedApplicantsFilter(response.data, positionId)); - setShowSpinner(false); - }) - .catch((data) => { - toast.error(t(KEY.common_something_went_wrong)); - console.error(data); - }); + const onInterviewChange = async () => { + for (const queryKey of allQueryKeys) { + await queryClient.invalidateQueries({ queryKey }); + } }; const title = t(KEY.recruitment_administrate_applications); @@ -127,9 +62,7 @@ export function RecruitmentPositionOverviewPage() { const backendUrl = reverse({ pattern: ROUTES.backend.admin__samfundet_recruitmentposition_change, - urlParams: { - objectId: positionId, - }, + urlParams: { objectId: positionId }, }); const header = ( @@ -138,10 +71,7 @@ export function RecruitmentPositionOverviewPage() { rounded={true} link={reverse({ pattern: ROUTES.frontend.admin_recruitment_gang_position_overview, - urlParams: { - gangId: gangId, - recruitmentId: recruitmentId, - }, + urlParams: { gangId, recruitmentId }, })} > {t(KEY.common_go_back)} @@ -149,77 +79,39 @@ export function RecruitmentPositionOverviewPage() { ); return ( - + - {lowerCapitalize(t(KEY.recruitment_applications))} ({recruitmentApplicants.length}) + {lowerCapitalize(t(KEY.recruitment_applications))} ({unprocessed.data?.data.length ?? 0})
- {t(KEY.recruitment_accepted_applications)} ({acceptedApplicants.length}) + {t(KEY.recruitment_accepted_applications)} ({accepted.data?.data.length ?? 0}) {t(KEY.recruitment_accepted_applications_help_text)} - {acceptedApplicants.length > 0 ? ( - - ) : ( - - {t(KEY.recruitment_accepted_applications_empty_text)} - - )} -
- -
- - {t(KEY.recruitment_rejected_applications)} ({rejectedApplicants.length}) - - {t(KEY.recruitment_rejected_applications_help_text)} - {rejectedApplicants.length > 0 ? ( - - ) : ( - - {t(KEY.recruitment_rejected_applications_empty_text)} - - )} -
-
- - {t(KEY.recruitment_hardtoget_applications)} ({hardtogetApplicants.length}) - - {t(KEY.recruitment_hardtoget_applications_help_text)} - {hardtogetApplicants.length > 0 ? ( + {(accepted.data?.data.length ?? 0) > 0 ? ( ) : ( - {t(KEY.recruitment_hardtoget_applications_empty_text)} + {t(KEY.recruitment_accepted_applications_empty_text)} )}
-
- - {t(KEY.recruitment_withdrawn_applications)} ({withdrawnApplicants.length}) - - {withdrawnApplicants.length > 0 ? ( - - ) : ( - - {' '} - {t(KEY.recruitment_withdrawn_applications_empty_text)} - - )} -
+ {/* Similar pattern for rejected, hardtoget, and withdrawn sections */} + {/* ... other sections follow the same pattern ... */}
); } diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 47b1da4b0..b35e7a4fd 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -811,7 +811,7 @@ export async function downloadCSVGangRecruitment(recruitmentId: string, gangId: export async function getRecruitmentApplicationsForRecruitmentPosition( positionId: string, filterType?: string, -): Promise> { +): Promise { const url = BACKEND_DOMAIN + reverse({ @@ -819,13 +819,14 @@ export async function getRecruitmentApplicationsForRecruitmentPosition( urlParams: { pk: positionId }, }); - // Add filter_type as a query parameter if it exists const params = filterType ? { filter_type: filterType } : {}; - return await axios.get(url, { + const response = await axios.get(url, { withCredentials: true, params: params, }); + + return response.data; } export async function putRecruitmentApplicationForGang( From c2e1a18133141921bb3a504812308874c7bebdfb Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Tue, 29 Oct 2024 19:10:08 +0100 Subject: [PATCH 05/16] add bad --- .../RecruitmentPositionOverviewPage.tsx | 82 ++++++++++--------- 1 file changed, 43 insertions(+), 39 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index d5948d5b3..c445b6196 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -1,19 +1,14 @@ -import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; +import { useQueryClient } from '@tanstack/react-query'; +import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; -import { toast } from 'react-toastify'; -import { Button, RecruitmentApplicantsStatus } from '~/Components'; -import { Text } from '~/Components/Text/Text'; -import { getRecruitmentApplicationsForRecruitmentPosition, updateRecruitmentApplicationStateForPosition } from '~/api'; -import type { RecruitmentApplicationStateDto } from '~/dto'; +import { Button } from '~/Components'; +import { getRecruitmentApplicationsForRecruitmentPosition } from '~/api'; import { useTitle } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; -import { lowerCapitalize } from '~/utils'; import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; -import styles from './RecruitmentPositionOverviewPage.module.scss'; -import { ProcessedApplicants } from './components'; const queryKeys = { applications: (positionId: string, filterType: string) => ['applications', positionId, filterType] as const, @@ -27,35 +22,44 @@ export function RecruitmentPositionOverviewPage() { // Store the query keys separately so we can use them for invalidation const filterTypes = ['unprocessed', 'withdrawn', 'hardtoget', 'rejected', 'accepted'] as const; - const allQueryKeys = filterTypes.map((filterType) => queryKeys.applications(positionId!, filterType)); + useEffect(() => { + if (!positionId) return; + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'unprocessed').then((response) => { + console.log(response); + }); + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'accepted').then((response) => { + console.log('ACCEPTED', response); + }); + }, [positionId]); + // const allQueryKeys = filterTypes.map((filterType) => queryKeys.applications(positionId!, filterType)); - const results = useQueries({ - queries: filterTypes.map((filterType) => ({ - queryKey: queryKeys.applications(positionId!, filterType), - queryFn: () => getRecruitmentApplicationsForRecruitmentPosition(positionId!, filterType), - enabled: !!positionId, - })), - }); + // const results = useQueries({ + // queries: filterTypes.map((filterType) => ({ + // queryKey: queryKeys.applications(positionId!, filterType), + // queryFn: () => getRecruitmentApplicationsForRecruitmentPosition(positionId!, filterType), + // enabled: !!positionId, + // })), + // }); - const updateMutation = useMutation({ - mutationFn: ({ id, data }: { id: string; data: RecruitmentApplicationStateDto }) => - updateRecruitmentApplicationStateForPosition(id, data), - onSuccess: async () => { - // Use the stored query keys for invalidation - for (const queryKey of allQueryKeys) { - await queryClient.invalidateQueries({ queryKey }); - } - }, - onError: () => { - toast.error(t(KEY.common_something_went_wrong)); - }, - }); + // const updateMutation = useMutation({ + // mutationFn: ({ id, data }: { id: string; data: RecruitmentApplicationStateDto }) => + // updateRecruitmentApplicationStateForPosition(id, data), + // onSuccess: async () => { + // // Use the stored query keys for invalidation + // for (const queryKey of allQueryKeys) { + // await queryClient.invalidateQueries({ queryKey }); + // } + // }, + // onError: () => { + // toast.error(t(KEY.common_something_went_wrong)); + // }, + // }); - const onInterviewChange = async () => { - for (const queryKey of allQueryKeys) { - await queryClient.invalidateQueries({ queryKey }); - } - }; + // const onInterviewChange = async () => { + // for (const queryKey of allQueryKeys) { + // await queryClient.invalidateQueries({ queryKey }); + // } + //}; const title = t(KEY.recruitment_administrate_applications); useTitle(title); @@ -79,8 +83,8 @@ export function RecruitmentPositionOverviewPage() { ); return ( - - + + {/* {lowerCapitalize(t(KEY.recruitment_applications))} ({unprocessed.data?.data.length ?? 0}) )} - - + */} +
test
{/* Similar pattern for rejected, hardtoget, and withdrawn sections */} {/* ... other sections follow the same pattern ... */}
From 313f72c2f4db78617a85b7c8e12bf8f2e27d4baa Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 31 Oct 2024 00:33:29 +0100 Subject: [PATCH 06/16] refactored RecruitmentApplicantsStatus to be a subcomponent in recruitment overview page and fetches all recruitment application "types" correctly --- frontend/src/Components/index.ts | 2 +- .../RecruitmentPositionOverviewPage.tsx | 174 ++++++++++++------ .../RecruitmentApplicantsStatus.module.scss | 0 .../RecruitmentApplicantsStatus.tsx | 7 +- .../RecruitmentApplicantsStatus/index.ts | 0 .../components/index.ts | 1 + 6 files changed, 121 insertions(+), 63 deletions(-) rename frontend/src/{Components => PagesAdmin/RecruitmentPositionOverviewPage/components}/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss (100%) rename frontend/src/{Components => PagesAdmin/RecruitmentPositionOverviewPage/components}/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx (97%) rename frontend/src/{Components => PagesAdmin/RecruitmentPositionOverviewPage/components}/RecruitmentApplicantsStatus/index.ts (100%) diff --git a/frontend/src/Components/index.ts b/frontend/src/Components/index.ts index c63343384..f1cffc04e 100644 --- a/frontend/src/Components/index.ts +++ b/frontend/src/Components/index.ts @@ -1,3 +1,4 @@ +export { RecruitmentApplicantsStatus } from '../PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus'; export { AccessDenied } from './AccessDenied'; export { AdminBox } from './AdminBox'; export { Alert } from './Alert'; @@ -55,7 +56,6 @@ export { ProgressBar } from './ProgressBar'; export { ProtectedRoute } from './ProtectedRoute'; export { PulseEffect } from './PulseEffect'; export { RadioButton } from './RadioButton'; -export { RecruitmentApplicantsStatus } from './RecruitmentApplicantsStatus'; export { RecruitmentWithoutInterviewTable } from './RecruitmentWithoutInterviewTable'; export { RootErrorBoundary } from './RootErrorBoundary'; export { SamfOutlet } from './SamfOutlet'; diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index c445b6196..60aa22802 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -1,65 +1,76 @@ -import { useQueryClient } from '@tanstack/react-query'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; -import { Button } from '~/Components'; +import { Button, RecruitmentApplicantsStatus, Text } from '~/Components'; import { getRecruitmentApplicationsForRecruitmentPosition } from '~/api'; +import type { RecruitmentApplicationDto, RecruitmentApplicationStateDto } from '~/dto'; import { useTitle } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; +import { lowerCapitalize } from '~/utils'; import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; - -const queryKeys = { - applications: (positionId: string, filterType: string) => ['applications', positionId, filterType] as const, -}; +import styles from './RecruitmentPositionOverviewPage.module.scss'; +import { ProcessedApplicants } from './components'; +// const queryKeys = { +// applications: (positionId: string, filterType: string) => ['applications', positionId, filterType] as const, +// }; export function RecruitmentPositionOverviewPage() { const navigate = useNavigate(); const { recruitmentId, gangId, positionId } = useParams(); - const queryClient = useQueryClient(); const { t } = useTranslation(); - // Store the query keys separately so we can use them for invalidation - const filterTypes = ['unprocessed', 'withdrawn', 'hardtoget', 'rejected', 'accepted'] as const; - useEffect(() => { + const [applications, setApplications] = useState<{ + unprocessed: RecruitmentApplicationDto[]; + withdrawn: RecruitmentApplicationDto[]; + hardtoget: RecruitmentApplicationDto[]; + rejected: RecruitmentApplicationDto[]; + accepted: RecruitmentApplicationDto[]; + }>({ + unprocessed: [], + withdrawn: [], + hardtoget: [], + rejected: [], + accepted: [], + }); + const [isLoading, setIsLoading] = useState(true); + + const loadApplications = async () => { if (!positionId) return; - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'unprocessed').then((response) => { - console.log(response); - }); - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'accepted').then((response) => { - console.log('ACCEPTED', response); - }); + + try { + const [unprocessed, accepted, withdrawn, hardtoget, rejected] = await Promise.all([ + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'unprocessed'), + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'accepted'), + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'withdrawn'), + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'hardtoget'), + getRecruitmentApplicationsForRecruitmentPosition(positionId, 'rejected'), + ]); + + setApplications({ + unprocessed: unprocessed || [], + accepted: accepted || [], + withdrawn: withdrawn || [], + hardtoget: hardtoget || [], + rejected: rejected || [], + }); + setIsLoading(false); + } catch (error) { + console.error('Error loading applications:', error); + setIsLoading(false); + } + }; + + useEffect(() => { + loadApplications(); }, [positionId]); - // const allQueryKeys = filterTypes.map((filterType) => queryKeys.applications(positionId!, filterType)); - - // const results = useQueries({ - // queries: filterTypes.map((filterType) => ({ - // queryKey: queryKeys.applications(positionId!, filterType), - // queryFn: () => getRecruitmentApplicationsForRecruitmentPosition(positionId!, filterType), - // enabled: !!positionId, - // })), - // }); - - // const updateMutation = useMutation({ - // mutationFn: ({ id, data }: { id: string; data: RecruitmentApplicationStateDto }) => - // updateRecruitmentApplicationStateForPosition(id, data), - // onSuccess: async () => { - // // Use the stored query keys for invalidation - // for (const queryKey of allQueryKeys) { - // await queryClient.invalidateQueries({ queryKey }); - // } - // }, - // onError: () => { - // toast.error(t(KEY.common_something_went_wrong)); - // }, - // }); - - // const onInterviewChange = async () => { - // for (const queryKey of allQueryKeys) { - // await queryClient.invalidateQueries({ queryKey }); - // } - //}; + + const updateApplicationState = async (id: string, data: RecruitmentApplicationStateDto) => { + // TODO: Implement the update function + // For now, just reload the data after update + await loadApplications(); + }; const title = t(KEY.recruitment_administrate_applications); useTitle(title); @@ -83,27 +94,28 @@ export function RecruitmentPositionOverviewPage() { ); return ( - - {/* - {lowerCapitalize(t(KEY.recruitment_applications))} ({unprocessed.data?.data.length ?? 0}) + + + {lowerCapitalize(t(KEY.recruitment_applications))} ({applications.unprocessed.length}) +
- {t(KEY.recruitment_accepted_applications)} ({accepted.data?.data.length ?? 0}) + {t(KEY.recruitment_accepted_applications)} ({applications.accepted.length}) {t(KEY.recruitment_accepted_applications_help_text)} - {(accepted.data?.data.length ?? 0) > 0 ? ( + {applications.accepted.length > 0 ? ( @@ -112,10 +124,56 @@ export function RecruitmentPositionOverviewPage() { {t(KEY.recruitment_accepted_applications_empty_text)} )} -
*/} -
test
- {/* Similar pattern for rejected, hardtoget, and withdrawn sections */} - {/* ... other sections follow the same pattern ... */} + + +
+ + {t(KEY.recruitment_rejected_applications)} ({applications.rejected.length}) + + {t(KEY.recruitment_rejected_applications_help_text)} + {applications.rejected.length > 0 ? ( + + ) : ( + + {t(KEY.recruitment_rejected_applications_empty_text)} + + )} +
+ +
+ + {t(KEY.recruitment_hardtoget_applications)} ({applications.hardtoget.length}) + + {t(KEY.recruitment_hardtoget_applications_help_text)} + {applications.hardtoget.length > 0 ? ( + + ) : ( + + {t(KEY.recruitment_hardtoget_applications_empty_text)} + + )} +
+ +
+ + {t(KEY.recruitment_withdrawn_applications)} ({applications.withdrawn.length}) + + {applications.withdrawn.length > 0 ? ( + + ) : ( + + {t(KEY.recruitment_withdrawn_applications_empty_text)} + + )} +
); } diff --git a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss similarity index 100% rename from frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss rename to frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss diff --git a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx similarity index 97% rename from frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx rename to frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx index 23103e743..8ea51b12f 100644 --- a/frontend/src/Components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx @@ -1,17 +1,16 @@ import { useTranslation } from 'react-i18next'; -import { InputField, TimeDisplay } from '~/Components'; +import { TimeDisplay } from '~/Components'; import { CrudButtons } from '~/Components/CrudButtons/CrudButtons'; import { Dropdown, type DropdownOption } from '~/Components/Dropdown/Dropdown'; import { Table } from '~/Components/Table'; import { Text } from '~/Components/Text/Text'; -import { putRecruitmentApplicationForGang } from '~/api'; import type { RecruitmentApplicationDto, RecruitmentApplicationStateDto } from '~/dto'; import { useCustomNavigate } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; -import { Link } from '../Link'; -import { SetInterviewManuallyModal } from '../SetInterviewManually'; +import { Link } from '../../../../Components/Link'; +import { SetInterviewManuallyModal } from '../../../../Components/SetInterviewManually'; import styles from './RecruitmentApplicantsStatus.module.scss'; type RecruitmentApplicantsStatusProps = { diff --git a/frontend/src/Components/RecruitmentApplicantsStatus/index.ts b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/index.ts similarity index 100% rename from frontend/src/Components/RecruitmentApplicantsStatus/index.ts rename to frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/index.ts diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/index.ts b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/index.ts index abf0af32a..58781ef6c 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/index.ts +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/index.ts @@ -1 +1,2 @@ export { ProcessedApplicants } from './ProcessedApplicants'; +export { RecruitmentApplicantsStatus } from './RecruitmentApplicantsStatus'; From 9ad595a649f9d10191ab34ad4ceee96dd1e5c101 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 31 Oct 2024 01:04:01 +0100 Subject: [PATCH 07/16] makes it possible to update application state --- ...ecruitmentPositionOverviewPage.module.scss | 6 + .../RecruitmentPositionOverviewPage.tsx | 142 +++++++++--------- 2 files changed, 78 insertions(+), 70 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss index 9fc064c48..6b0ad0f2d 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss @@ -1,3 +1,9 @@ +.container { + display: flex; + flex-direction: column; + flex-wrap: wrap; + gap: 1rem; +} .sub_container { margin-top: 1.5em; diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index 60aa22802..b4f896c74 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate, useParams } from 'react-router-dom'; import { Button, RecruitmentApplicantsStatus, Text } from '~/Components'; -import { getRecruitmentApplicationsForRecruitmentPosition } from '~/api'; +import { getRecruitmentApplicationsForRecruitmentPosition, updateRecruitmentApplicationStateForPosition } from '~/api'; import type { RecruitmentApplicationDto, RecruitmentApplicationStateDto } from '~/dto'; import { useTitle } from '~/hooks'; import { KEY } from '~/i18n/constants'; @@ -12,9 +12,6 @@ import { lowerCapitalize } from '~/utils'; import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; import styles from './RecruitmentPositionOverviewPage.module.scss'; import { ProcessedApplicants } from './components'; -// const queryKeys = { -// applications: (positionId: string, filterType: string) => ['applications', positionId, filterType] as const, -// }; export function RecruitmentPositionOverviewPage() { const navigate = useNavigate(); @@ -34,6 +31,7 @@ export function RecruitmentPositionOverviewPage() { rejected: [], accepted: [], }); + const [isLoading, setIsLoading] = useState(true); const loadApplications = async () => { @@ -66,10 +64,13 @@ export function RecruitmentPositionOverviewPage() { loadApplications(); }, [positionId]); - const updateApplicationState = async (id: string, data: RecruitmentApplicationStateDto) => { - // TODO: Implement the update function - // For now, just reload the data after update - await loadApplications(); + const updateApplicationState = (id: string, data: RecruitmentApplicationStateDto) => { + if (!recruitmentId) return; + updateRecruitmentApplicationStateForPosition(id, data).then((response) => { + //setApplications(response.data); + console.log('UPDATED RECRUITMENT APPLICATION STATE', response); + loadApplications(); + }); }; const title = t(KEY.recruitment_administrate_applications); @@ -107,72 +108,73 @@ export function RecruitmentPositionOverviewPage() { updateStateFunction={updateApplicationState} onInterviewChange={loadApplications} /> - -
- - {t(KEY.recruitment_accepted_applications)} ({applications.accepted.length}) - - {t(KEY.recruitment_accepted_applications_help_text)} - {applications.accepted.length > 0 ? ( - - ) : ( - - {t(KEY.recruitment_accepted_applications_empty_text)} +
+
+ + {t(KEY.recruitment_accepted_applications)} ({applications.accepted.length}) - )} -
- -
- - {t(KEY.recruitment_rejected_applications)} ({applications.rejected.length}) - - {t(KEY.recruitment_rejected_applications_help_text)} - {applications.rejected.length > 0 ? ( - - ) : ( - - {t(KEY.recruitment_rejected_applications_empty_text)} + {t(KEY.recruitment_accepted_applications_help_text)} + {applications.accepted.length > 0 ? ( + + ) : ( + + {t(KEY.recruitment_accepted_applications_empty_text)} + + )} +
+ +
+ + {t(KEY.recruitment_rejected_applications)} ({applications.rejected.length}) - )} -
- -
- - {t(KEY.recruitment_hardtoget_applications)} ({applications.hardtoget.length}) - - {t(KEY.recruitment_hardtoget_applications_help_text)} - {applications.hardtoget.length > 0 ? ( - - ) : ( - - {t(KEY.recruitment_hardtoget_applications_empty_text)} + {t(KEY.recruitment_rejected_applications_help_text)} + {applications.rejected.length > 0 ? ( + + ) : ( + + {t(KEY.recruitment_rejected_applications_empty_text)} + + )} +
+ +
+ + {t(KEY.recruitment_hardtoget_applications)} ({applications.hardtoget.length}) - )} -
- -
- - {t(KEY.recruitment_withdrawn_applications)} ({applications.withdrawn.length}) - - {applications.withdrawn.length > 0 ? ( - - ) : ( - - {t(KEY.recruitment_withdrawn_applications_empty_text)} + {t(KEY.recruitment_hardtoget_applications_help_text)} + {applications.hardtoget.length > 0 ? ( + + ) : ( + + {t(KEY.recruitment_hardtoget_applications_empty_text)} + + )} +
+ +
+ + {t(KEY.recruitment_withdrawn_applications)} ({applications.withdrawn.length}) - )} + {applications.withdrawn.length > 0 ? ( + + ) : ( + + {t(KEY.recruitment_withdrawn_applications_empty_text)} + + )} +
); From e5ec7b42577d619e99d4343fbf8a0f69497d41de Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 31 Oct 2024 01:53:32 +0100 Subject: [PATCH 08/16] resolving biome and tc issues --- .../RecruitmentPositionOverviewPage.tsx | 266 ++++++++++-------- 1 file changed, 145 insertions(+), 121 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index b4f896c74..ae706c935 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -1,10 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useParams } from 'react-router-dom'; +import { toast } from 'react-toastify'; import { Button, RecruitmentApplicantsStatus, Text } from '~/Components'; import { getRecruitmentApplicationsForRecruitmentPosition, updateRecruitmentApplicationStateForPosition } from '~/api'; import type { RecruitmentApplicationDto, RecruitmentApplicationStateDto } from '~/dto'; -import { useTitle } from '~/hooks'; +import { useCustomNavigate, useTitle } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; @@ -13,64 +14,100 @@ import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; import styles from './RecruitmentPositionOverviewPage.module.scss'; import { ProcessedApplicants } from './components'; +const APPLICATION_CATEGORY = ['unprocessed', 'withdrawn', 'hardtoget', 'rejected', 'accepted'] as const; +type ApplicationCategory = (typeof APPLICATION_CATEGORY)[number]; + +const queryKeys = { + applications: (positionId: string, type: ApplicationCategory) => ['applications', positionId, type] as const, +}; + export function RecruitmentPositionOverviewPage() { - const navigate = useNavigate(); const { recruitmentId, gangId, positionId } = useParams(); const { t } = useTranslation(); + const queryClient = useQueryClient(); + const navigate = useCustomNavigate(); + + if (!positionId || !recruitmentId || !gangId) { + toast.error(t(KEY.common_something_went_wrong)); + navigate({ url: -1 }); + return null; + } + + const queries = useQueries({ + queries: APPLICATION_CATEGORY.map((type) => ({ + queryKey: queryKeys.applications(positionId, type), + queryFn: () => getRecruitmentApplicationsForRecruitmentPosition(positionId, type), + enabled: !!positionId, + })), + }); - const [applications, setApplications] = useState<{ - unprocessed: RecruitmentApplicationDto[]; - withdrawn: RecruitmentApplicationDto[]; - hardtoget: RecruitmentApplicationDto[]; - rejected: RecruitmentApplicationDto[]; - accepted: RecruitmentApplicationDto[]; - }>({ - unprocessed: [], - withdrawn: [], - hardtoget: [], - rejected: [], - accepted: [], + const isLoading = queries.some((query) => query.isLoading); + const applications = Object.fromEntries( + queries.map((query, index) => [APPLICATION_CATEGORY[index], query.data || []]), + ) as Record; + + const updateMutation = useMutation({ + mutationFn: ({ id, data }: { id: string; data: RecruitmentApplicationStateDto }) => + updateRecruitmentApplicationStateForPosition(id, data), + onMutate: async ({ id, data }) => { + // Cancel any outgoing refetches so they don't overwrite our optimistic update + await queryClient.cancelQueries(); + + // Create a type-safe container for previous data + const previousData: Partial> = {}; + + // Safely store previous data, handling undefined cases + for (const type of APPLICATION_CATEGORY) { + const queryData = queryClient.getQueryData( + queryKeys.applications(positionId, type), + ); + if (queryData) { + previousData[type] = queryData; + } + } + + // Perform optimistic updates + for (const type of APPLICATION_CATEGORY) { + queryClient.setQueryData(queryKeys.applications(positionId, type), (old) => { + if (!old) return []; + return old.map((app) => (app.id === id ? { ...app, ...data } : app)); + }); + } + + return { previousData }; + }, + onError: (_, __, context) => { + // Safely restore previous data + if (context?.previousData) { + for (const type of APPLICATION_CATEGORY) { + const previousTypeData = context.previousData[type]; + if (previousTypeData) { + queryClient.setQueryData(queryKeys.applications(positionId, type), previousTypeData); + } + } + } + toast.error(t(KEY.common_something_went_wrong)); + }, + onSuccess: () => { + // Invalidate and refetch all application queries + for (const type of APPLICATION_CATEGORY) { + queryClient.invalidateQueries({ + queryKey: queryKeys.applications(positionId, type), + }); + } + }, }); - const [isLoading, setIsLoading] = useState(true); - - const loadApplications = async () => { - if (!positionId) return; - - try { - const [unprocessed, accepted, withdrawn, hardtoget, rejected] = await Promise.all([ - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'unprocessed'), - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'accepted'), - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'withdrawn'), - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'hardtoget'), - getRecruitmentApplicationsForRecruitmentPosition(positionId, 'rejected'), - ]); - - setApplications({ - unprocessed: unprocessed || [], - accepted: accepted || [], - withdrawn: withdrawn || [], - hardtoget: hardtoget || [], - rejected: rejected || [], - }); - setIsLoading(false); - } catch (error) { - console.error('Error loading applications:', error); - setIsLoading(false); - } + const updateApplicationState = (id: string, data: RecruitmentApplicationStateDto) => { + updateMutation.mutate({ id, data }); }; - useEffect(() => { - loadApplications(); - }, [positionId]); - - const updateApplicationState = (id: string, data: RecruitmentApplicationStateDto) => { - if (!recruitmentId) return; - updateRecruitmentApplicationStateForPosition(id, data).then((response) => { - //setApplications(response.data); - console.log('UPDATED RECRUITMENT APPLICATION STATE', response); - loadApplications(); - }); + const invalidateAllQueries = () => { + for (const type of APPLICATION_CATEGORY) { + queryClient.invalidateQueries({ + queryKey: queryKeys.applications(positionId, type), + }); + } }; const title = t(KEY.recruitment_administrate_applications); @@ -94,87 +131,74 @@ export function RecruitmentPositionOverviewPage() { ); + const applicationSections = [ + { + type: 'accepted' as const, + title: KEY.recruitment_accepted_applications, + helpText: KEY.recruitment_accepted_applications_help_text, + emptyText: KEY.recruitment_accepted_applications_empty_text, + }, + { + type: 'rejected' as const, + title: KEY.recruitment_rejected_applications, + helpText: KEY.recruitment_rejected_applications_help_text, + emptyText: KEY.recruitment_rejected_applications_empty_text, + }, + { + type: 'hardtoget' as const, + title: KEY.recruitment_hardtoget_applications, + helpText: KEY.recruitment_hardtoget_applications_help_text, + emptyText: KEY.recruitment_hardtoget_applications_empty_text, + }, + { + type: 'withdrawn' as const, + title: KEY.recruitment_withdrawn_applications, + helpText: '', + emptyText: KEY.recruitment_withdrawn_applications_empty_text, + }, + ]; + return ( - {lowerCapitalize(t(KEY.recruitment_applications))} ({applications.unprocessed.length}) + {lowerCapitalize(t(KEY.recruitment_applications))} ({applications.unprocessed?.length || 0}) { + for (const type of APPLICATION_CATEGORY) { + queryClient.invalidateQueries({ + queryKey: queryKeys.applications(positionId, type), + }); + } + }} /> +
-
- - {t(KEY.recruitment_accepted_applications)} ({applications.accepted.length}) - - {t(KEY.recruitment_accepted_applications_help_text)} - {applications.accepted.length > 0 ? ( - - ) : ( - - {t(KEY.recruitment_accepted_applications_empty_text)} - - )} -
- -
- - {t(KEY.recruitment_rejected_applications)} ({applications.rejected.length}) - - {t(KEY.recruitment_rejected_applications_help_text)} - {applications.rejected.length > 0 ? ( - - ) : ( - - {t(KEY.recruitment_rejected_applications_empty_text)} - - )} -
- -
- - {t(KEY.recruitment_hardtoget_applications)} ({applications.hardtoget.length}) - - {t(KEY.recruitment_hardtoget_applications_help_text)} - {applications.hardtoget.length > 0 ? ( - - ) : ( - - {t(KEY.recruitment_hardtoget_applications_empty_text)} - - )} -
- -
- - {t(KEY.recruitment_withdrawn_applications)} ({applications.withdrawn.length}) - - {applications.withdrawn.length > 0 ? ( - - ) : ( - - {t(KEY.recruitment_withdrawn_applications_empty_text)} + {applicationSections.map(({ type, title, helpText, emptyText }) => ( +
+ + {t(title)} ({applications[type]?.length || 0}) - )} -
+ {helpText && {t(helpText)}} + {applications[type]?.length > 0 ? ( + + ) : ( + + {t(emptyText)} + + )} +
+ ))}
); From a499f2b194c4f0c5928093406ce4355eecc64ccb Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 31 Oct 2024 02:15:17 +0100 Subject: [PATCH 09/16] added issue for todo (#1575) --- .../RecruitmentPositionOverviewPage.tsx | 1 + .../RecruitmentApplicantsStatus.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index ae706c935..bd8588808 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -14,6 +14,7 @@ import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; import styles from './RecruitmentPositionOverviewPage.module.scss'; import { ProcessedApplicants } from './components'; +// TODO add backend to fetch these. ISSUE #1575 const APPLICATION_CATEGORY = ['unprocessed', 'withdrawn', 'hardtoget', 'rejected', 'accepted'] as const; type ApplicationCategory = (typeof APPLICATION_CATEGORY)[number]; diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx index 8ea51b12f..203dbbe57 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx @@ -22,7 +22,7 @@ type RecruitmentApplicantsStatusProps = { onInterviewChange: () => void; }; -// TODO add backend to fetch these +// TODO add backend to fetch these ISSUE #1575 const priorityOptions: DropdownOption[] = [ { label: 'Not Set', value: 0 }, { label: 'Reserve', value: 1 }, @@ -30,6 +30,7 @@ const priorityOptions: DropdownOption[] = [ { label: 'Not Wanted', value: 3 }, ]; +// TODO add backend to fetch these ISSUE #1575 const statusOptions: DropdownOption[] = [ { label: 'Nothing', value: 0 }, { label: 'Called and accepted', value: 1 }, From 76ff72db7a438d8d72324e53bffb0c1898b993cf Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 31 Oct 2024 03:51:23 +0100 Subject: [PATCH 10/16] lowers complexity in RecruitmentApplicationForRecruitmentPositionView --- backend/samfundet/views.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/backend/samfundet/views.py b/backend/samfundet/views.py index 61af87cef..ef4b3131f 100644 --- a/backend/samfundet/views.py +++ b/backend/samfundet/views.py @@ -1070,11 +1070,21 @@ def put(self, request: Request, pk: int) -> Response: class RecruitmentApplicationForRecruitmentPositionView(ModelViewSet): + # TODO: refactor this in ISSUE #1575 permission_classes = [IsAuthenticated] serializer_class = RecruitmentApplicationForGangSerializer queryset = RecruitmentApplication.objects.all() - def retrieve(self, request: Request, pk: int, *args: Any, **kwargs: Any) -> Response: # noqa: C901 + # Define filter mappings as a class attribute + FILTER_MAPPINGS = { + 'unprocessed': {'withdrawn': False, 'recruiter_status': RecruitmentStatusChoices.NOT_SET}, + 'withdrawn': {'withdrawn': True}, + 'hardtoget': {'withdrawn': False, 'recruiter_status': RecruitmentStatusChoices.CALLED_AND_REJECTED}, + 'accepted': {'withdrawn': False, 'recruiter_status': RecruitmentStatusChoices.CALLED_AND_ACCEPTED}, + 'rejected': {'withdrawn': False, 'recruiter_status': RecruitmentStatusChoices.REJECTION}, + } + + def retrieve(self, request: Request, pk: int) -> Response: """ Retrieve filtered applications for a specific recruitment position. If no filter_type is provided, returns all applications. @@ -1086,20 +1096,11 @@ def retrieve(self, request: Request, pk: int, *args: Any, **kwargs: Any) -> Resp applications = self.get_queryset().filter(recruitment_position=position) filter_type = request.query_params.get('filter_type') - if filter_type: - if filter_type == 'unprocessed': - applications = applications.filter(withdrawn=False, recruiter_status=RecruitmentStatusChoices.NOT_SET) - elif filter_type == 'withdrawn': - applications = applications.filter(withdrawn=True) - elif filter_type == 'hardtoget': - applications = applications.filter(withdrawn=False, recruiter_status=RecruitmentStatusChoices.CALLED_AND_REJECTED) - elif filter_type == 'accepted': - applications = applications.filter(withdrawn=False, recruiter_status=RecruitmentStatusChoices.CALLED_AND_ACCEPTED) - elif filter_type == 'rejected': - applications = applications.filter(withdrawn=False, recruiter_status=RecruitmentStatusChoices.REJECTION) - else: + filter_params = self.FILTER_MAPPINGS.get(filter_type) + if not filter_params: return Response({'error': 'Invalid filter_type parameter'}, status=status.HTTP_400_BAD_REQUEST) + applications = applications.filter(**filter_params) serializer = self.get_serializer(applications, many=True) return Response(data=serializer.data) From d5cd7bc3e17e98173d0bc45d89a087523e19c1ec Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 31 Oct 2024 03:51:37 +0100 Subject: [PATCH 11/16] fetcehs position data --- .../RecruitmentPositionOverviewPage.tsx | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index bd8588808..c814cc3ce 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -1,9 +1,14 @@ import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; import { Button, RecruitmentApplicantsStatus, Text } from '~/Components'; -import { getRecruitmentApplicationsForRecruitmentPosition, updateRecruitmentApplicationStateForPosition } from '~/api'; +import { + getRecruitmentApplicationsForRecruitmentPosition, + getRecruitmentPosition, + updateRecruitmentApplicationStateForPosition, +} from '~/api'; import type { RecruitmentApplicationDto, RecruitmentApplicationStateDto } from '~/dto'; import { useCustomNavigate, useTitle } from '~/hooks'; import { KEY } from '~/i18n/constants'; @@ -27,6 +32,7 @@ export function RecruitmentPositionOverviewPage() { const { t } = useTranslation(); const queryClient = useQueryClient(); const navigate = useCustomNavigate(); + const [positionName, setPositionName] = useState(); if (!positionId || !recruitmentId || !gangId) { toast.error(t(KEY.common_something_went_wrong)); @@ -34,6 +40,12 @@ export function RecruitmentPositionOverviewPage() { return null; } + useEffect(() => { + getRecruitmentPosition(positionId).then((response) => { + setPositionName(response.data.name_nb); + }); + }, [positionId]); + const queries = useQueries({ queries: APPLICATION_CATEGORY.map((type) => ({ queryKey: queryKeys.applications(positionId, type), @@ -113,6 +125,7 @@ export function RecruitmentPositionOverviewPage() { const title = t(KEY.recruitment_administrate_applications); useTitle(title); + const headerTitle = `${t(KEY.recruitment_administrate_applications)} for ${positionName ? positionName : 'N/A'}`; const backendUrl = reverse({ pattern: ROUTES.backend.admin__samfundet_recruitmentposition_change, @@ -160,7 +173,7 @@ export function RecruitmentPositionOverviewPage() { ]; return ( - + {lowerCapitalize(t(KEY.recruitment_applications))} ({applications.unprocessed?.length || 0}) From 9197c7927bbaf50a05048a477964e32dd0303529 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 31 Oct 2024 03:57:19 +0100 Subject: [PATCH 12/16] implements react query for fetching position --- .../RecruitmentPositionOverviewPage.tsx | 42 +++++++------------ 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index c814cc3ce..d96675ddb 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -1,5 +1,4 @@ -import { useMutation, useQueries, useQueryClient } from '@tanstack/react-query'; -import { useEffect, useState } from 'react'; +import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; @@ -14,7 +13,7 @@ import { useCustomNavigate, useTitle } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; -import { lowerCapitalize } from '~/utils'; +import { dbT, lowerCapitalize } from '~/utils'; import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; import styles from './RecruitmentPositionOverviewPage.module.scss'; import { ProcessedApplicants } from './components'; @@ -25,6 +24,7 @@ type ApplicationCategory = (typeof APPLICATION_CATEGORY)[number]; const queryKeys = { applications: (positionId: string, type: ApplicationCategory) => ['applications', positionId, type] as const, + position: (positionId: string) => ['position', positionId] as const, }; export function RecruitmentPositionOverviewPage() { @@ -32,7 +32,6 @@ export function RecruitmentPositionOverviewPage() { const { t } = useTranslation(); const queryClient = useQueryClient(); const navigate = useCustomNavigate(); - const [positionName, setPositionName] = useState(); if (!positionId || !recruitmentId || !gangId) { toast.error(t(KEY.common_something_went_wrong)); @@ -40,13 +39,14 @@ export function RecruitmentPositionOverviewPage() { return null; } - useEffect(() => { - getRecruitmentPosition(positionId).then((response) => { - setPositionName(response.data.name_nb); - }); - }, [positionId]); + // Query for position details + const positionQuery = useQuery({ + queryKey: queryKeys.position(positionId), + queryFn: () => getRecruitmentPosition(positionId), + }); - const queries = useQueries({ + // Queries for applications + const applicationQueries = useQueries({ queries: APPLICATION_CATEGORY.map((type) => ({ queryKey: queryKeys.applications(positionId, type), queryFn: () => getRecruitmentApplicationsForRecruitmentPosition(positionId, type), @@ -54,22 +54,20 @@ export function RecruitmentPositionOverviewPage() { })), }); - const isLoading = queries.some((query) => query.isLoading); + const isLoading = applicationQueries.some((query) => query.isLoading) || positionQuery.isLoading; + const applications = Object.fromEntries( - queries.map((query, index) => [APPLICATION_CATEGORY[index], query.data || []]), + applicationQueries.map((query, index) => [APPLICATION_CATEGORY[index], query.data || []]), ) as Record; const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: string; data: RecruitmentApplicationStateDto }) => updateRecruitmentApplicationStateForPosition(id, data), onMutate: async ({ id, data }) => { - // Cancel any outgoing refetches so they don't overwrite our optimistic update await queryClient.cancelQueries(); - // Create a type-safe container for previous data const previousData: Partial> = {}; - // Safely store previous data, handling undefined cases for (const type of APPLICATION_CATEGORY) { const queryData = queryClient.getQueryData( queryKeys.applications(positionId, type), @@ -79,7 +77,6 @@ export function RecruitmentPositionOverviewPage() { } } - // Perform optimistic updates for (const type of APPLICATION_CATEGORY) { queryClient.setQueryData(queryKeys.applications(positionId, type), (old) => { if (!old) return []; @@ -90,7 +87,6 @@ export function RecruitmentPositionOverviewPage() { return { previousData }; }, onError: (_, __, context) => { - // Safely restore previous data if (context?.previousData) { for (const type of APPLICATION_CATEGORY) { const previousTypeData = context.previousData[type]; @@ -102,7 +98,6 @@ export function RecruitmentPositionOverviewPage() { toast.error(t(KEY.common_something_went_wrong)); }, onSuccess: () => { - // Invalidate and refetch all application queries for (const type of APPLICATION_CATEGORY) { queryClient.invalidateQueries({ queryKey: queryKeys.applications(positionId, type), @@ -115,18 +110,9 @@ export function RecruitmentPositionOverviewPage() { updateMutation.mutate({ id, data }); }; - const invalidateAllQueries = () => { - for (const type of APPLICATION_CATEGORY) { - queryClient.invalidateQueries({ - queryKey: queryKeys.applications(positionId, type), - }); - } - }; - const title = t(KEY.recruitment_administrate_applications); useTitle(title); - const headerTitle = `${t(KEY.recruitment_administrate_applications)} for ${positionName ? positionName : 'N/A'}`; - + const headerTitle = `${t(KEY.recruitment_administrate_applications)} for ${positionQuery.data ? dbT(positionQuery.data?.data, 'name') : 'N/A'}`; const backendUrl = reverse({ pattern: ROUTES.backend.admin__samfundet_recruitmentposition_change, urlParams: { objectId: positionId }, From b4a944b02158c86339bb569df686e069614fede7 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 31 Oct 2024 04:34:12 +0100 Subject: [PATCH 13/16] adds loading indicator and diables dropdown when loading application state update --- .../RecruitmentPositionOverviewPage.tsx | 13 +++++- .../RecruitmentApplicantsStatus.module.scss | 11 ++++- .../RecruitmentApplicantsStatus.tsx | 42 ++++++++++++------- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index d96675ddb..c48bc7b00 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -1,4 +1,5 @@ import { useMutation, useQueries, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; @@ -32,6 +33,7 @@ export function RecruitmentPositionOverviewPage() { const { t } = useTranslation(); const queryClient = useQueryClient(); const navigate = useCustomNavigate(); + const [updatingId, setUpdatingId] = useState(null); if (!positionId || !recruitmentId || !gangId) { toast.error(t(KEY.common_something_went_wrong)); @@ -107,7 +109,15 @@ export function RecruitmentPositionOverviewPage() { }); const updateApplicationState = (id: string, data: RecruitmentApplicationStateDto) => { - updateMutation.mutate({ id, data }); + setUpdatingId(id); + updateMutation.mutate( + { id, data }, + { + onSettled: () => { + setUpdatingId(null); + }, + }, + ); }; const title = t(KEY.recruitment_administrate_applications); @@ -165,6 +175,7 @@ export function RecruitmentPositionOverviewPage() {
void; onInterviewChange: () => void; + updatingId: string | null; }; // TODO add backend to fetch these ISSUE #1575 @@ -46,6 +48,7 @@ const editChoices = { }; export function RecruitmentApplicantsStatus({ + updatingId, applicants, recruitmentId, gangId, @@ -98,6 +101,7 @@ export function RecruitmentApplicantsStatus({ const data = applicants.map((application) => { const applicationStatusStyle = getStatusStyle(application?.applicant_state); + const isUpdating = updatingId === application.id; return { cells: [ { @@ -161,26 +165,34 @@ export function RecruitmentApplicantsStatus({ value: application.recruiter_priority, style: applicationStatusStyle, content: ( - updateApplications(application.id, editChoices.update_recruitment_priority, value)} - /> + + {isUpdating && } + updateApplications(application.id, editChoices.update_recruitment_priority, value)} + /> + ), }, { value: application.recruiter_status, style: applicationStatusStyle, content: ( - updateApplications(application.id, editChoices.update_recruitment_status, value)} - /> + + {isUpdating && } + updateApplications(application.id, editChoices.update_recruitment_status, value)} + /> + ), }, { From 8d3b783eeca8b73418f8a72970388e2912fa012d Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 31 Oct 2024 04:47:32 +0100 Subject: [PATCH 14/16] adds comments and removes backend url --- .../RecruitmentPositionOverviewPage.tsx | 66 +++++++++++-------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index c48bc7b00..5b29e8c77 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -19,10 +19,14 @@ import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; import styles from './RecruitmentPositionOverviewPage.module.scss'; import { ProcessedApplicants } from './components'; +// Define the possible states an application can be in within the recruitment process +// Applications flow through these states as they are processed by recruiters // TODO add backend to fetch these. ISSUE #1575 const APPLICATION_CATEGORY = ['unprocessed', 'withdrawn', 'hardtoget', 'rejected', 'accepted'] as const; type ApplicationCategory = (typeof APPLICATION_CATEGORY)[number]; +// Define query keys for React Query cache management +// These keys are used to organize and invalidate cached data efficiently const queryKeys = { applications: (positionId: string, type: ApplicationCategory) => ['applications', positionId, type] as const, position: (positionId: string) => ['position', positionId] as const, @@ -33,21 +37,25 @@ export function RecruitmentPositionOverviewPage() { const { t } = useTranslation(); const queryClient = useQueryClient(); const navigate = useCustomNavigate(); + + // Track which application is currently being updated to show loading state const [updatingId, setUpdatingId] = useState(null); + // Validate required URL parameters if (!positionId || !recruitmentId || !gangId) { toast.error(t(KEY.common_something_went_wrong)); navigate({ url: -1 }); return null; } - // Query for position details + // Fetch details about the recruitment position const positionQuery = useQuery({ queryKey: queryKeys.position(positionId), queryFn: () => getRecruitmentPosition(positionId), }); - // Queries for applications + // Fetch all applications for each possible application state in parallel + // This allows us to show all categories of applications simultaneously const applicationQueries = useQueries({ queries: APPLICATION_CATEGORY.map((type) => ({ queryKey: queryKeys.applications(positionId, type), @@ -58,18 +66,24 @@ export function RecruitmentPositionOverviewPage() { const isLoading = applicationQueries.some((query) => query.isLoading) || positionQuery.isLoading; + // Organize application data by category for easier access const applications = Object.fromEntries( applicationQueries.map((query, index) => [APPLICATION_CATEGORY[index], query.data || []]), ) as Record; + // Handle updating application states with optimistic updates const updateMutation = useMutation({ mutationFn: ({ id, data }: { id: string; data: RecruitmentApplicationStateDto }) => updateRecruitmentApplicationStateForPosition(id, data), + // Optimistically update the UI before the server responds onMutate: async ({ id, data }) => { + // Cancel any outgoing refetches to avoid overwriting our optimistic update await queryClient.cancelQueries(); + // Store the current state to roll back if the mutation fails const previousData: Partial> = {}; + // Save current state for all application categories for (const type of APPLICATION_CATEGORY) { const queryData = queryClient.getQueryData( queryKeys.applications(positionId, type), @@ -78,16 +92,17 @@ export function RecruitmentPositionOverviewPage() { previousData[type] = queryData; } } - + // Optimistically update all relevant queries for (const type of APPLICATION_CATEGORY) { queryClient.setQueryData(queryKeys.applications(positionId, type), (old) => { if (!old) return []; - return old.map((app) => (app.id === id ? { ...app, ...data } : app)); + return old.map((application) => (application.id === id ? { ...application, ...data } : application)); }); } return { previousData }; }, + // If mutation fails, roll back to the previous state onError: (_, __, context) => { if (context?.previousData) { for (const type of APPLICATION_CATEGORY) { @@ -99,6 +114,7 @@ export function RecruitmentPositionOverviewPage() { } toast.error(t(KEY.common_something_went_wrong)); }, + // On successful mutation, invalidate and refetch all application queries onSuccess: () => { for (const type of APPLICATION_CATEGORY) { queryClient.invalidateQueries({ @@ -108,6 +124,7 @@ export function RecruitmentPositionOverviewPage() { }, }); + // Wrapper function to update application state with loading indicator const updateApplicationState = (id: string, data: RecruitmentApplicationStateDto) => { setUpdatingId(id); updateMutation.mutate( @@ -120,27 +137,7 @@ export function RecruitmentPositionOverviewPage() { ); }; - const title = t(KEY.recruitment_administrate_applications); - useTitle(title); - const headerTitle = `${t(KEY.recruitment_administrate_applications)} for ${positionQuery.data ? dbT(positionQuery.data?.data, 'name') : 'N/A'}`; - const backendUrl = reverse({ - pattern: ROUTES.backend.admin__samfundet_recruitmentposition_change, - urlParams: { objectId: positionId }, - }); - - const header = ( - - ); - + // Define sections for different application categories with their respective texts const applicationSections = [ { type: 'accepted' as const, @@ -168,8 +165,25 @@ export function RecruitmentPositionOverviewPage() { }, ]; + const title = t(KEY.recruitment_administrate_applications); + useTitle(title); + const headerTitle = `${t(KEY.recruitment_administrate_applications)} for ${positionQuery.data ? dbT(positionQuery.data?.data, 'name') : 'N/A'}`; + + const header = ( + + ); + return ( - + {lowerCapitalize(t(KEY.recruitment_applications))} ({applications.unprocessed?.length || 0}) From af5d05d01b7a07c5b946bba6890d96ad4528e2d7 Mon Sep 17 00:00:00 2001 From: Snorre Jr Date: Thu, 31 Oct 2024 05:03:58 +0100 Subject: [PATCH 15/16] refactor imports and small changes after self review --- frontend/src/Components/Dropdown/index.ts | 2 +- frontend/src/Components/index.ts | 4 +- ...ecruitmentPositionOverviewPage.module.scss | 7 ---- .../RecruitmentPositionOverviewPage.tsx | 42 +++++++++---------- .../RecruitmentApplicantsStatus.module.scss | 2 +- .../RecruitmentApplicantsStatus.tsx | 22 ++++++---- 6 files changed, 37 insertions(+), 42 deletions(-) diff --git a/frontend/src/Components/Dropdown/index.ts b/frontend/src/Components/Dropdown/index.ts index c11eca394..73287634f 100644 --- a/frontend/src/Components/Dropdown/index.ts +++ b/frontend/src/Components/Dropdown/index.ts @@ -1,2 +1,2 @@ export { Dropdown } from './Dropdown'; -export type { DropdownProps } from './Dropdown'; +export type { DropdownProps, DropdownOption } from './Dropdown'; diff --git a/frontend/src/Components/index.ts b/frontend/src/Components/index.ts index f1cffc04e..8ccd87139 100644 --- a/frontend/src/Components/index.ts +++ b/frontend/src/Components/index.ts @@ -1,4 +1,3 @@ -export { RecruitmentApplicantsStatus } from '../PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus'; export { AccessDenied } from './AccessDenied'; export { AdminBox } from './AdminBox'; export { Alert } from './Alert'; @@ -63,6 +62,7 @@ export { SamfundetLogo } from './SamfundetLogo'; export { SamfundetLogoSpinner } from './SamfundetLogoSpinner'; export { useScrollToTop } from './ScrollToTop'; export { Select } from './Select'; +export { SetInterviewManuallyModal } from './SetInterviewManually'; export { Skeleton } from './Skeleton'; export { SpinningBorder } from './SpinningBorder'; export { SplashHeaderBox } from './SplashHeaderBox'; @@ -89,7 +89,7 @@ export { Video } from './Video'; // Props export type { CheckboxProps } from './Checkbox'; -export type { DropdownProps } from './Dropdown'; +export type { DropdownOption, DropdownProps } from './Dropdown'; export type { ImagePickerProps } from './ImagePicker/ImagePicker'; export type { InputFieldProps } from './InputField'; export type { InputFileProps } from './InputFile'; diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss index 6b0ad0f2d..214a789a8 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.module.scss @@ -1,10 +1,3 @@ -.container { - display: flex; - flex-direction: column; - flex-wrap: wrap; - gap: 1rem; -} - .sub_container { margin-top: 1.5em; } diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index 5b29e8c77..8f599c1e4 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; import { toast } from 'react-toastify'; -import { Button, RecruitmentApplicantsStatus, Text } from '~/Components'; +import { Button, Text } from '~/Components'; import { getRecruitmentApplicationsForRecruitmentPosition, getRecruitmentPosition, @@ -17,7 +17,7 @@ import { ROUTES } from '~/routes'; import { dbT, lowerCapitalize } from '~/utils'; import { AdminPageLayout } from '../AdminPageLayout/AdminPageLayout'; import styles from './RecruitmentPositionOverviewPage.module.scss'; -import { ProcessedApplicants } from './components'; +import { ProcessedApplicants, RecruitmentApplicantsStatus } from './components'; // Define the possible states an application can be in within the recruitment process // Applications flow through these states as they are processed by recruiters @@ -204,27 +204,25 @@ export function RecruitmentPositionOverviewPage() { }} /> -
- {applicationSections.map(({ type, title, helpText, emptyText }) => ( -
- - {t(title)} ({applications[type]?.length || 0}) + {applicationSections.map(({ type, title, helpText, emptyText }) => ( +
+ + {t(title)} ({applications[type]?.length || 0}) + + {helpText && {t(helpText)}} + {applications[type]?.length > 0 ? ( + + ) : ( + + {t(emptyText)} - {helpText && {t(helpText)}} - {applications[type]?.length > 0 ? ( - - ) : ( - - {t(emptyText)} - - )} -
- ))} -
+ )} +
+ ))}
); } diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss index cad99a052..cb0ed02eb 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.module.scss @@ -92,7 +92,7 @@ justify-content: center; } -.loadingApplState { +.loadingApplicationStatus { display: block; position: absolute; width: 1rem; diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx index 5a50f349c..a1d2124d6 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/components/RecruitmentApplicantsStatus/RecruitmentApplicantsStatus.tsx @@ -1,17 +1,21 @@ import { Fragment } from 'react'; import { useTranslation } from 'react-i18next'; -import { SamfundetLogoSpinner, TimeDisplay } from '~/Components'; -import { CrudButtons } from '~/Components/CrudButtons/CrudButtons'; -import { Dropdown, type DropdownOption } from '~/Components/Dropdown/Dropdown'; -import { Table } from '~/Components/Table'; -import { Text } from '~/Components/Text/Text'; +import { + CrudButtons, + Dropdown, + type DropdownOption, + Link, + SamfundetLogoSpinner, + SetInterviewManuallyModal, + Table, + Text, + TimeDisplay, +} from '~/Components'; import type { RecruitmentApplicationDto, RecruitmentApplicationStateDto } from '~/dto'; import { useCustomNavigate } from '~/hooks'; import { KEY } from '~/i18n/constants'; import { reverse } from '~/named-urls'; import { ROUTES } from '~/routes'; -import { Link } from '../../../../Components/Link'; -import { SetInterviewManuallyModal } from '../../../../Components/SetInterviewManually'; import styles from './RecruitmentApplicantsStatus.module.scss'; type RecruitmentApplicantsStatusProps = { @@ -166,7 +170,7 @@ export function RecruitmentApplicantsStatus({ style: applicationStatusStyle, content: ( - {isUpdating && } + {isUpdating && } - {isUpdating && } + {isUpdating && } Date: Thu, 31 Oct 2024 06:00:02 +0100 Subject: [PATCH 16/16] adds more accurat title to applicants which have no status --- .../RecruitmentPositionOverviewPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx index 8f599c1e4..8b4d23963 100644 --- a/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx +++ b/frontend/src/PagesAdmin/RecruitmentPositionOverviewPage/RecruitmentPositionOverviewPage.tsx @@ -185,7 +185,7 @@ export function RecruitmentPositionOverviewPage() { return ( - {lowerCapitalize(t(KEY.recruitment_applications))} ({applications.unprocessed?.length || 0}) + {lowerCapitalize(t(KEY.recruitment_unprocessed_applicants))} ({applications.unprocessed?.length || 0})