diff --git a/backend/degree/views.py b/backend/degree/views.py index a569f8aa..46c098f6 100644 --- a/backend/degree/views.py +++ b/backend/degree/views.py @@ -84,6 +84,20 @@ def retrieve(self, request, *args, **kwargs): serializer = self.get_serializer(degree_plan) return Response(serializer.data, status=status.HTTP_200_OK) + def update(self, request, *args, **kwargs): + name = request.data.get("name") + instance = self.get_object() + if ( + name + and instance.name != name + and DegreePlan.objects.filter(name=name, person=request.user).exists() + ): + return Response( + {"warning": f"A degree plan with name {name} already exists."}, + status=status.HTTP_409_CONFLICT, + ) + return super().update(request, *args, **kwargs) + def create(self, request, *args, **kwargs): name = request.data.get("name") if name is None: diff --git a/frontend/degree-plan/components/FourYearPlan/DegreeModal.tsx b/frontend/degree-plan/components/FourYearPlan/DegreeModal.tsx index 3ff4a388..73ec3519 100644 --- a/frontend/degree-plan/components/FourYearPlan/DegreeModal.tsx +++ b/frontend/degree-plan/components/FourYearPlan/DegreeModal.tsx @@ -7,11 +7,13 @@ import type { SchoolOption, } from "@/types"; import React, { useState, useEffect } from "react"; -import { deleteFetcher, postFetcher, useSWRCrud } from "@/hooks/swrcrud"; +import { deleteFetcher, postFetcher, useSWRCrud, getCsrf, normalizeFinalSlash } from "@/hooks/swrcrud"; import useSWR, { useSWRConfig } from "swr"; import ModalContainer from "../common/ModalContainer"; import Select from "react-select"; import { schoolOptions } from "@/components/OnboardingPanels/SharedComponents"; +import { ErrorText } from "@/components/OnboardingPanels/SharedComponents"; +import { assertValueType } from "@/types"; export type ModalKey = | "plan-create" @@ -157,7 +159,6 @@ const ModalInterior = ({ }: ModalInteriorProps) => { const { create: createDegreeplan, - update: updateDegreeplan, remove: deleteDegreeplan, } = useSWRCrud("/api/degree/degreeplans"); @@ -222,6 +223,85 @@ const ModalInterior = ({ await mutate(`/api/degree/degreeplans/${degreeplanId}`); // use updated degree plan returned }; + + + // Update degree plan handling error case where degree plan of same name already exists. + const [sameNameError, setSameNameError] = useState(false); + + const updateDegreeplanWithErrorHandling = async (updatedData: Partial, id: number | string | null) => { + if (!id) return; + + const key = normalizeFinalSlash(`/api/degree/degreeplans/${id}`); + const res = await fetch(key, { + credentials: "include", + mode: "same-origin", + method: "PATCH", + headers: { + "Content-Type": "application/json", + "X-CSRFToken": getCsrf(), + "Accept": "application/json", + } as HeadersInit, + body: JSON.stringify({ name: name }), + }); + + if (res.ok) { + const updated = await res.json(); + + // Handle mutation + // Code adapted from swrcrud.ts + const idKey = "id" as keyof DegreePlan; + + mutate(key, updated, { + optimisticData: (data?: DegreePlan) => { + const optimistic = {...data, ...updatedData} as DegreePlan; + assertValueType(optimistic, idKey, id) + optimistic.id = Number(id); // does this work? + return ({ id, ...data, ...updatedData} as DegreePlan) + }, + revalidate: false, + throwOnError: false + }) + + const endpoint = "/api/degree/degreeplans"; + mutate(endpoint, updated, { + optimisticData: (list?: Array) => { + if (!list) return []; + const index = list.findIndex((item: DegreePlan) => String(item[idKey]) === id); + if (index === -1) { + mutate(endpoint) // trigger revalidation + return list; + } + list.splice(index, 1, {...list[index], ...updatedData}); + return list; + }, + populateCache: (updated: DegreePlan, list?: Array) => { + if (!list) return []; + if (!updated) return list; + const index = list.findIndex((item: DegreePlan) => item[idKey] === updated[idKey]); + if (index === -1) { + console.warn("swrcrud: update: updated element not found in list view"); + mutate(endpoint); // trigger revalidation + return list; + } + list.splice(index, 1, updated); + return list + }, + revalidate: false, + throwOnError: false + }) + + close(); // only close if update is successful + } else if (res.status === 409) { + setSameNameError(true); + + setTimeout(() => { + setSameNameError(false); + }, 5000); + } else { + throw new Error(await res.text()); + } + }; + switch (modalKey) { case "plan-create": return ( @@ -262,18 +342,20 @@ const ModalInterior = ({ "id" in modalObject && "name" in modalObject ) { - updateDegreeplan({ name }, modalObject.id); + updateDegreeplanWithErrorHandling({name: name}, modalObject.id); if (modalObject.id == activeDegreePlan?.id) { let newNameDegPlan = modalObject; newNameDegPlan.name = name; setActiveDegreeplan(newNameDegPlan); } + } else { + close(); } - close(); }} > Rename + {sameNameError && A degree plan with this name already exists.} ); case "plan-remove": diff --git a/frontend/degree-plan/hooks/swrcrud.ts b/frontend/degree-plan/hooks/swrcrud.ts index 877b2427..a13b19a6 100644 --- a/frontend/degree-plan/hooks/swrcrud.ts +++ b/frontend/degree-plan/hooks/swrcrud.ts @@ -65,7 +65,7 @@ export const patchFetcher = baseFetcher({ method: "PATCH" }) export const putFetcher = baseFetcher({ method: "PUT" }) export const deleteFetcher = baseFetcher({ method: "DELETE" }, false); // expect no response from delete requests -const normalizeFinalSlash = (resource: string) => { +export const normalizeFinalSlash = (resource: string) => { if (!resource.endsWith("/")) resource += "/"; return resource }