From 914f550d5d2405be5defcac623a6ff46e60de1ab Mon Sep 17 00:00:00 2001 From: Jason Young Date: Sat, 5 Apr 2025 06:30:45 -0400 Subject: [PATCH 01/23] turn off debug logging for production --- react/src/Challenge.tsx | 40 +++++++++++++++++++++++++++------------- react/src/Tools.tsx | 1 + 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/react/src/Challenge.tsx b/react/src/Challenge.tsx index 0ce9f6ff..bc522521 100644 --- a/react/src/Challenge.tsx +++ b/react/src/Challenge.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from 'react'; +import React, {useState} from 'react'; import Container from "react-bootstrap/Container"; import {NavHeader} from "./App"; import {useParams} from "react-router-dom"; @@ -9,7 +9,8 @@ import CollapsibleChallenge from "./component/CollapsibleChallenge"; import useApiDelete from "./hooks/useApiDelete"; import CreateChallenge from "./CreateChallenge"; import {toChallengeList} from "./utility/Mapper"; -import {ChallengeDetailDto} from "./types/challenge.types"; +import {ChallengeData, ChallengeDetailDto, ChallengeList} from "./types/challenge.types"; +import {useQuery, useQueryClient} from "@tanstack/react-query"; function Challenge() { @@ -17,30 +18,43 @@ function Challenge() { const callDelete = useApiDelete(); // the user's current date is used to determine challenge completion status - const challengeEndpoint = `/api/user/${publicId}/challenge`; + const challengeUrl = `/api/user/${publicId}/challenge`; - const [createdCount, setCreatedCount] = useState(0); const [deletedCount, setDeletedCount] = useState(0); - const [savedChallenges, setSavedChallenges] = useState(emptyChallengeList); - useEffect(() => { - fetch(challengeEndpoint, GET) - .then((response) => response.json() as Promise) - .then(toChallengeList) - .then(setSavedChallenges) - .catch(error => console.log(error)); - }, [createdCount, deletedCount]); + + // TODO Replace useDelete with TSQ mutation + // update state when deleted + const queryClient = useQueryClient(); const deleteChallenge = (challengeId: number) => { const endpoint = `/api/user/${publicId}/challenge/${challengeId}`; callDelete(endpoint).then(() => setDeletedCount(deletedCount + 1)); } + // TODO ChallengeList doesn't need to be parameterized, maybe it needed to be in the past + + const fetchChallenges = () => fetch(challengeUrl, GET) + .then((response) => response.json() as Promise); + + + // TODO update state when created + // passing saved challenges to CreateChallenge is really only used for prop drilling + // to do validation on new names to prevent name collisions + + const { data: savedChallenges = emptyChallengeList } = useQuery>({ + queryKey: [challengeUrl], + queryFn: fetchChallenges, + placeholderData: [] as ChallengeDetailDto[], + staleTime: Infinity, + select: (data: ChallengeDetailDto[]) => toChallengeList(data), + }); + return ( - setCreatedCount(createdCount+1)} + {}} savedChallenges={[...savedChallenges.completed, ...savedChallenges.current, ...savedChallenges.upcoming]} /> diff --git a/react/src/Tools.tsx b/react/src/Tools.tsx index 8e4d003d..129eff03 100644 --- a/react/src/Tools.tsx +++ b/react/src/Tools.tsx @@ -91,6 +91,7 @@ function Tools() { mutationFn: (variables: UploadFileVariables) => uploadFileFunction(variables), onSuccess: (data: RecordCount) => { onUploadSuccess(data); + // TODO does this need to be "| undefined"? queryClient.setQueryData([sleepCountUrl], (oldData: RecordCount | undefined) => ({ numRecords: data.numRecords + (oldData?.numRecords ?? 0), })); From 769bf4f6afc25e88e95ab28b616f4e50132bca12 Mon Sep 17 00:00:00 2001 From: Jason Young Date: Sun, 6 Apr 2025 07:28:04 -0400 Subject: [PATCH 02/23] broken state, working on ChallengeForm --- react/src/Challenge.tsx | 15 ++---- react/src/ChallengeForm.tsx | 1 + react/src/Chat.tsx | 2 +- react/src/CreateChallenge.tsx | 88 +++++++++++++++++++++++++--------- react/src/Tools.tsx | 1 - react/src/utility/apiClient.ts | 7 ++- 6 files changed, 75 insertions(+), 39 deletions(-) diff --git a/react/src/Challenge.tsx b/react/src/Challenge.tsx index bc522521..fa1f935d 100644 --- a/react/src/Challenge.tsx +++ b/react/src/Challenge.tsx @@ -20,13 +20,12 @@ function Challenge() { // the user's current date is used to determine challenge completion status const challengeUrl = `/api/user/${publicId}/challenge`; - const [deletedCount, setDeletedCount] = useState(0); // TODO Replace useDelete with TSQ mutation // update state when deleted const queryClient = useQueryClient(); - + const [deletedCount, setDeletedCount] = useState(0); const deleteChallenge = (challengeId: number) => { const endpoint = `/api/user/${publicId}/challenge/${challengeId}`; callDelete(endpoint).then(() => setDeletedCount(deletedCount + 1)); @@ -34,14 +33,12 @@ function Challenge() { // TODO ChallengeList doesn't need to be parameterized, maybe it needed to be in the past + // TODO should this be httpGet()? + // and should we use httpGet in Tools? + const fetchChallenges = () => fetch(challengeUrl, GET) .then((response) => response.json() as Promise); - - // TODO update state when created - // passing saved challenges to CreateChallenge is really only used for prop drilling - // to do validation on new names to prevent name collisions - const { data: savedChallenges = emptyChallengeList } = useQuery>({ queryKey: [challengeUrl], queryFn: fetchChallenges, @@ -54,9 +51,7 @@ function Challenge() { - {}} - savedChallenges={[...savedChallenges.completed, ...savedChallenges.current, ...savedChallenges.upcoming]} - /> + diff --git a/react/src/ChallengeForm.tsx b/react/src/ChallengeForm.tsx index 6c422ae3..360d31a2 100644 --- a/react/src/ChallengeForm.tsx +++ b/react/src/ChallengeForm.tsx @@ -24,6 +24,7 @@ function ChallengeForm(props:{ savedChallenges:ChallengeData[]} ) { + const {setEditableChallenge, editableChallenge, setDataValid, savedChallenges} = props; // validation of individual fields for validation feedback to the user diff --git a/react/src/Chat.tsx b/react/src/Chat.tsx index 988ba4aa..c7470d45 100644 --- a/react/src/Chat.tsx +++ b/react/src/Chat.tsx @@ -35,7 +35,7 @@ function Chat() { }); const uploadMessageMutation = useMutation({ - mutationFn: (variables: PostVariables) => httpPost(variables.url, variables.body), + mutationFn: (variables: PostVariables) => httpPost(variables.url, variables.body), onSuccess: (message: MessageDto) => { setShowProcessingIcon(false); queryClient.setQueryData([chatUrl], (oldData: MessageDto[] | undefined) => [ diff --git a/react/src/CreateChallenge.tsx b/react/src/CreateChallenge.tsx index 6557961c..07dd1062 100644 --- a/react/src/CreateChallenge.tsx +++ b/react/src/CreateChallenge.tsx @@ -3,24 +3,20 @@ import Container from "react-bootstrap/Container"; import Button from "react-bootstrap/Button"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faPlus} from "@fortawesome/free-solid-svg-icons"; -import {useParams} from "react-router-dom"; import Modal from "react-bootstrap/Modal"; import Alert from "react-bootstrap/Alert"; -import useApiPost from "./hooks/useApiPost"; import CollapsibleContent from "./component/CollapsibleContent"; import {emptyEditableChallenge, NameDescription, PREDEFINED_CHALLENGES} from "./utility/Constants"; import SuccessModal from "./component/SuccessModal"; import {toChallengeDto} from "./utility/Mapper"; import ChallengeForm from "./ChallengeForm"; -import {ChallengeData} from "./types/challenge.types"; +import {useMutation, useQueryClient} from "@tanstack/react-query"; +import {ChallengeData, ChallengeDto} from "./types/challenge.types.ts"; +import {httpPost, PostVariables} from "./utility/apiClient.ts"; -function CreateChallenge(props: {onCreated:()=>void, savedChallenges:ChallengeData[]}) { +function CreateChallenge(props: {challengeUrl:string}) { - const {onCreated, savedChallenges} = props; - - const {publicId} = useParams(); - - const challengeEndpoint = `/api/user/${publicId}/challenge`; + const {challengeUrl} = props; const [showCreateSuccess, setShowCreateSuccess] = useState(false); const [showCreateChallenge, setShowCreateChallenge] = useState(false); @@ -31,15 +27,6 @@ function CreateChallenge(props: {onCreated:()=>void, savedChallenges:ChallengeDa // this is set as invalid to start so the name can be blank before showing validation messages const [dataValid, setDataValid] = useState(false); - const post = useApiPost(); - - const saveData = () => { - post(challengeEndpoint, toChallengeDto(editableChallenge)) - .then(clearChallengeEdit) - .then(() => setShowCreateSuccess(true)) - .then(onCreated); - } - const clearChallengeEdit = () => { setShowCreateChallenge(false); setEditableChallenge(emptyEditableChallenge()); @@ -59,6 +46,61 @@ function CreateChallenge(props: {onCreated:()=>void, savedChallenges:ChallengeDa setShowPredefinedChallenges( ! showPredefinedChallenges); } + // TODO update state when created + // passing saved challenges to CreateChallenge is really only used for prop drilling + // to do validation on new names to prevent name collisions. + // can pass along the TSQ key instead + + + const queryClient = useQueryClient(); + + // TODO pass the query key at the use site instead of passing the data + const savedChallenges = queryClient.getQueryData([challengeUrl]) ?? []; + + const uploadNewChallenge = useMutation({ + mutationFn: (vars: PostVariables) => httpPost(vars.url, vars.body), + onSuccess: (challenge: ChallengeDto) => { + + console.log("Challenge created successfully"); + setShowCreateSuccess(true); + clearChallengeEdit(); + + // the object returned from the server is passed to the onSuccess function + // TODO challenge controller should return the created object + // it will have the id and created date and be able to be rendered when the new query data is set + + // TODO add the response data to the query cache since the id should be in the response + + // TODO if we do nothing with the promise does this still run? + // TODO should we use the refetchType where we use invalidateQueries elsewhere? + // TODO what's the default refetch type? + queryClient.invalidateQueries( { + queryKey: [challengeUrl], + refetchType: 'all', + }); + + console.log("invalidated queries"); + + // TODO does this need to be "| undefined"? See also Tools, same question + // queryClient.setQueryData([challengeUrl], (oldData: ChallengeDto[] | undefined) => [ + // ...(oldData ?? []), + // challenge, + // ]); + }, + }); + + const saveEditableChallenge = () => { + uploadNewChallenge.mutate({ + url: challengeUrl, + body: toChallengeDto(editableChallenge) + }); + }; + + console.log("Saved challenges") + console.log(savedChallenges) + console.log("Editable Challenge") + console.log(editableChallenge) + return ( <> - + {/**/}
-
diff --git a/react/src/Tools.tsx b/react/src/Tools.tsx index 129eff03..8e4d003d 100644 --- a/react/src/Tools.tsx +++ b/react/src/Tools.tsx @@ -91,7 +91,6 @@ function Tools() { mutationFn: (variables: UploadFileVariables) => uploadFileFunction(variables), onSuccess: (data: RecordCount) => { onUploadSuccess(data); - // TODO does this need to be "| undefined"? queryClient.setQueryData([sleepCountUrl], (oldData: RecordCount | undefined) => ({ numRecords: data.numRecords + (oldData?.numRecords ?? 0), })); diff --git a/react/src/utility/apiClient.ts b/react/src/utility/apiClient.ts index 052f22f2..27e0b685 100644 --- a/react/src/utility/apiClient.ts +++ b/react/src/utility/apiClient.ts @@ -1,8 +1,7 @@ -import {MessageDto} from "../types/message.types.ts"; -interface PostVariables { +interface PostVariables { url: string; - body: MessageDto; + body: T; } type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; @@ -30,7 +29,7 @@ const httpGet = async (url: string) => { return data as T; } -const httpPost = async (url: string, body: Record) => { +const httpPost = async (url: string, body: T) => { const requestMeta = buildRequestMeta('POST', JSON.stringify(body)); const response = await fetch(url, requestMeta); const data = await response.json(); From 0cdb6ffeef0a11c65d1fcb9f925ea3ffb58277f0 Mon Sep 17 00:00:00 2001 From: Jason Young Date: Sat, 12 Apr 2025 16:41:16 -0400 Subject: [PATCH 03/23] fix create challenge bug --- react/src/Challenge.tsx | 15 ++-------- react/src/ChallengeForm.tsx | 13 +++++--- react/src/CreateChallenge.tsx | 51 +++++++++++++------------------- react/src/EditChallenge.tsx | 13 +++++--- react/src/hooks/useChallenges.ts | 34 +++++++++++++++++++++ react/src/utility/Constants.ts | 2 -- react/src/utility/Mapper.ts | 7 +---- 7 files changed, 76 insertions(+), 59 deletions(-) create mode 100644 react/src/hooks/useChallenges.ts diff --git a/react/src/Challenge.tsx b/react/src/Challenge.tsx index fa1f935d..0be83b37 100644 --- a/react/src/Challenge.tsx +++ b/react/src/Challenge.tsx @@ -11,6 +11,7 @@ import CreateChallenge from "./CreateChallenge"; import {toChallengeList} from "./utility/Mapper"; import {ChallengeData, ChallengeDetailDto, ChallengeList} from "./types/challenge.types"; import {useQuery, useQueryClient} from "@tanstack/react-query"; +import {useChallenges} from "./hooks/useChallenges.ts"; function Challenge() { @@ -33,19 +34,7 @@ function Challenge() { // TODO ChallengeList doesn't need to be parameterized, maybe it needed to be in the past - // TODO should this be httpGet()? - // and should we use httpGet in Tools? - - const fetchChallenges = () => fetch(challengeUrl, GET) - .then((response) => response.json() as Promise); - - const { data: savedChallenges = emptyChallengeList } = useQuery>({ - queryKey: [challengeUrl], - queryFn: fetchChallenges, - placeholderData: [] as ChallengeDetailDto[], - staleTime: Infinity, - select: (data: ChallengeDetailDto[]) => toChallengeList(data), - }); + const { data: savedChallenges = emptyChallengeList } = useChallenges(challengeUrl); return ( diff --git a/react/src/ChallengeForm.tsx b/react/src/ChallengeForm.tsx index 360d31a2..ffd62146 100644 --- a/react/src/ChallengeForm.tsx +++ b/react/src/ChallengeForm.tsx @@ -7,7 +7,7 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {faExclamationTriangle} from "@fortawesome/free-solid-svg-icons"; import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; -import {ChallengeData} from "./types/challenge.types"; +import {ChallengeData, ChallengeList} from "./types/challenge.types"; import {localDateToJsDate, jsDateToLocalDate} from "./utility/Mapper.ts"; @@ -21,7 +21,7 @@ function ChallengeForm(props:{ setEditableChallenge:React.Dispatch> editableChallenge:ChallengeData, setDataValid:React.Dispatch>, - savedChallenges:ChallengeData[]} + savedChallenges:ChallengeList} ) { @@ -47,9 +47,14 @@ function ChallengeForm(props:{ : true; + const flattenedChallenges = [] as ChallengeData[]; + flattenedChallenges.push(...savedChallenges.completed); + flattenedChallenges.push(...savedChallenges.current); + flattenedChallenges.push(...savedChallenges.upcoming); + const dateOrderValid = challenge.start.isBefore(challenge.finish); - const nameUnique = !savedChallenges.some(saved => saved.name === challenge.name); - const datesOverlap = savedChallenges.some(saved => overlaps(challenge, saved) ); + const nameUnique = !flattenedChallenges.some(saved => saved.name === challenge.name); + const datesOverlap = flattenedChallenges.some(saved => overlaps(challenge, saved) ); setDateOrderValid(dateOrderValid); setNameValid(nameValid); diff --git a/react/src/CreateChallenge.tsx b/react/src/CreateChallenge.tsx index 07dd1062..13ac3649 100644 --- a/react/src/CreateChallenge.tsx +++ b/react/src/CreateChallenge.tsx @@ -6,13 +6,14 @@ import {faPlus} from "@fortawesome/free-solid-svg-icons"; import Modal from "react-bootstrap/Modal"; import Alert from "react-bootstrap/Alert"; import CollapsibleContent from "./component/CollapsibleContent"; -import {emptyEditableChallenge, NameDescription, PREDEFINED_CHALLENGES} from "./utility/Constants"; +import {emptyChallengeList, emptyEditableChallenge, NameDescription, PREDEFINED_CHALLENGES} from "./utility/Constants"; import SuccessModal from "./component/SuccessModal"; import {toChallengeDto} from "./utility/Mapper"; import ChallengeForm from "./ChallengeForm"; import {useMutation, useQueryClient} from "@tanstack/react-query"; -import {ChallengeData, ChallengeDto} from "./types/challenge.types.ts"; +import {ChallengeDto} from "./types/challenge.types.ts"; import {httpPost, PostVariables} from "./utility/apiClient.ts"; +import {useChallenges} from "./hooks/useChallenges.ts"; function CreateChallenge(props: {challengeUrl:string}) { @@ -46,46 +47,39 @@ function CreateChallenge(props: {challengeUrl:string}) { setShowPredefinedChallenges( ! showPredefinedChallenges); } - // TODO update state when created - // passing saved challenges to CreateChallenge is really only used for prop drilling - // to do validation on new names to prevent name collisions. - // can pass along the TSQ key instead - - const queryClient = useQueryClient(); - // TODO pass the query key at the use site instead of passing the data - const savedChallenges = queryClient.getQueryData([challengeUrl]) ?? []; + const { data: savedChallenges = emptyChallengeList } = useChallenges(challengeUrl); + const uploadNewChallenge = useMutation({ mutationFn: (vars: PostVariables) => httpPost(vars.url, vars.body), onSuccess: (challenge: ChallengeDto) => { + // the object returned from the server is passed to the onSuccess function console.log("Challenge created successfully"); setShowCreateSuccess(true); clearChallengeEdit(); - // the object returned from the server is passed to the onSuccess function - // TODO challenge controller should return the created object + // TODO update state when created + // challenge controller should return the created object // it will have the id and created date and be able to be rendered when the new query data is set - - // TODO add the response data to the query cache since the id should be in the response + // add the response data to the query cache since the id should be in the response + // TODO does this need to be "| undefined"? See also Tools, same question + // queryClient.setQueryData([challengeUrl], (oldData: ChallengeDto[] | undefined) => [ + // ...(oldData ?? []), + // challenge, + // ]); // TODO if we do nothing with the promise does this still run? // TODO should we use the refetchType where we use invalidateQueries elsewhere? // TODO what's the default refetch type? - queryClient.invalidateQueries( { + queryClient.invalidateQueries({ queryKey: [challengeUrl], refetchType: 'all', - }); + }).then(r =>{}); console.log("invalidated queries"); - - // TODO does this need to be "| undefined"? See also Tools, same question - // queryClient.setQueryData([challengeUrl], (oldData: ChallengeDto[] | undefined) => [ - // ...(oldData ?? []), - // challenge, - // ]); }, }); @@ -96,10 +90,7 @@ function CreateChallenge(props: {challengeUrl:string}) { }); }; - console.log("Saved challenges") - console.log(savedChallenges) - console.log("Editable Challenge") - console.log(editableChallenge) + // TODO pass the query key instead of savedChallenges to ChallengeForm return ( <> @@ -115,10 +106,10 @@ function CreateChallenge(props: {challengeUrl:string}) { - {/**/} +
diff --git a/react/src/EditChallenge.tsx b/react/src/EditChallenge.tsx index 4316bae2..18d345ce 100644 --- a/react/src/EditChallenge.tsx +++ b/react/src/EditChallenge.tsx @@ -9,8 +9,13 @@ import {NavHeader} from "./App"; import {useNavigate, useParams} from "react-router-dom"; import WarningButton from "./component/WarningButton"; import ChallengeForm from "./ChallengeForm"; -import {emptyChallengeDataArray, emptyEditableChallenge} from "./utility/Constants"; -import { toLocalChallengeData, toChallengeDto, toLocalChallengeDataList, toChallengeDetailDto} from "./utility/Mapper"; +import {emptyChallengeList, emptyEditableChallenge} from "./utility/Constants"; +import { + toLocalChallengeData, + toChallengeDto, + toChallengeDetailDto, + toChallengeList +} from "./utility/Mapper"; import {ChallengeDetailDto, ChallengeDto} from "./types/challenge.types"; const removeChallengesWithId = (challengeList: ChallengeDetailDto[], challengeId: number) => { @@ -36,7 +41,7 @@ function EditChallenge() { const [loaded, setLoaded] = useState(false); const [editableChallenge, setEditableChallenge] = useState(emptyEditableChallenge()); const [dataValid, setDataValid] = useState(true); - const [savedChallenges, setSavedChallenges] = useState(emptyChallengeDataArray); + const [savedChallenges, setSavedChallenges] = useState(emptyChallengeList); const put = useApiPut(); const callDelete = useApiDelete(); @@ -56,7 +61,7 @@ function EditChallenge() { fetch(allChallengesEndpoint, GET) .then((response) => response.json() as Promise) .then(challengeList => removeChallengesWithId(challengeList, numericChallengeId)) - .then(toLocalChallengeDataList) + .then(toChallengeList) .then(setSavedChallenges) .catch(error => console.log(error)); }, [allChallengesEndpoint]); diff --git a/react/src/hooks/useChallenges.ts b/react/src/hooks/useChallenges.ts new file mode 100644 index 00000000..42cfcf59 --- /dev/null +++ b/react/src/hooks/useChallenges.ts @@ -0,0 +1,34 @@ +// This custom hook is used to fetch challenge data from an API endpoint and +// transform it into a format suitable for the UI. By encapsulating all query +// logic—including the data fetching, caching, and mapping—in this hook, we ensure +// that every component that consumes this data gets the same, consistent output. +// It also prevents duplication of code and minimizes the risk of divergence if +// the query configuration ever needs to change. + +import { useQuery } from '@tanstack/react-query'; +import {ChallengeData, ChallengeDetailDto, ChallengeList} from "../types/challenge.types.ts"; +import {toChallengeList} from "../utility/Mapper.ts"; +import {GET} from "../utility/BasicHeaders.ts"; + + +// Custom hook that encapsulates the TanStack Query logic for fetching challenges. +// The queryKey uses the challengeUrl to ensure the query is uniquely identified. +export const useChallenges = (challengeUrl: string) => { + + + // TODO should this be httpGet()? + // and should we use httpGet in Tools? + + const fetchChallenges = () => fetch(challengeUrl, GET) + .then((response) => response.json() as Promise); + + return useQuery>({ + queryKey: [challengeUrl], + queryFn: fetchChallenges, + placeholderData: [] as ChallengeDetailDto[], + // Keep the data fresh as long as the component is mounted. + staleTime: Infinity, + // The select function transforms the fetched data into UI-friendly objects. + select: (data: ChallengeDetailDto[]) => toChallengeList(data), + }); +}; diff --git a/react/src/utility/Constants.ts b/react/src/utility/Constants.ts index 2a5b1f8c..c6e609bb 100644 --- a/react/src/utility/Constants.ts +++ b/react/src/utility/Constants.ts @@ -53,8 +53,6 @@ export const emptyEditableChallenge = ():ChallengeData => { }; } -export const emptyChallengeDataArray: ChallengeData[] = []; - export const emptyChallengeList: ChallengeList = { current: [], upcoming: [], diff --git a/react/src/utility/Mapper.ts b/react/src/utility/Mapper.ts index 8de66fae..357a740b 100644 --- a/react/src/utility/Mapper.ts +++ b/react/src/utility/Mapper.ts @@ -58,11 +58,6 @@ function calculateProgress(challenge: ChallengeData): number { return Math.round((elapsedDuration / totalDuration) * 100); } -const toLocalChallengeDataList = (challengeList: ChallengeDetailDto[]): ChallengeData[] => { - return challengeList.map(toLocalChallengeData); -} - - const toChallengeList = (challengeList: ChallengeDetailDto[]): ChallengeList => { const challengeData = challengeList.map(toLocalChallengeData); @@ -130,7 +125,7 @@ const toSleepDto = (sleep: SleepData): SleepDto => { } export { - toSelectableChallenges, toChallengeDto, toLocalChallengeData, toLocalChallengeDataList, toChallengeDetailDto, + toSelectableChallenges, toChallengeDto, toLocalChallengeData, toChallengeDetailDto, toLocalSleepData, toSleepDto, calculateProgress, toChallengeList, jsDateToLocalDate, localDateToJsDate, jsDateToLocalDateTime, localDateTimeToJsDate, localDateToString } From bcf15044d23513d63ae85abaf63c2403868dd5c7 Mon Sep 17 00:00:00 2001 From: Jason Young Date: Sat, 12 Apr 2025 16:55:48 -0400 Subject: [PATCH 04/23] invalidate cache on save --- react/src/CreateChallenge.tsx | 13 +++++-------- .../server/controller/ChallengeController.java | 4 ++-- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/react/src/CreateChallenge.tsx b/react/src/CreateChallenge.tsx index 13ac3649..58caa889 100644 --- a/react/src/CreateChallenge.tsx +++ b/react/src/CreateChallenge.tsx @@ -61,11 +61,10 @@ function CreateChallenge(props: {challengeUrl:string}) { setShowCreateSuccess(true); clearChallengeEdit(); - // TODO update state when created - // challenge controller should return the created object - // it will have the id and created date and be able to be rendered when the new query data is set - // add the response data to the query cache since the id should be in the response - // TODO does this need to be "| undefined"? See also Tools, same question + // TODO update state when created, controller returned the created object + + // TODO does this need to be "| undefined"? + // See also Tools, same question // queryClient.setQueryData([challengeUrl], (oldData: ChallengeDto[] | undefined) => [ // ...(oldData ?? []), // challenge, @@ -73,13 +72,11 @@ function CreateChallenge(props: {challengeUrl:string}) { // TODO if we do nothing with the promise does this still run? // TODO should we use the refetchType where we use invalidateQueries elsewhere? - // TODO what's the default refetch type? + // what's the default refetchType? queryClient.invalidateQueries({ queryKey: [challengeUrl], refetchType: 'all', }).then(r =>{}); - - console.log("invalidated queries"); }, }); diff --git a/server/src/main/java/com/seebie/server/controller/ChallengeController.java b/server/src/main/java/com/seebie/server/controller/ChallengeController.java index 6c4a0009..07f57474 100644 --- a/server/src/main/java/com/seebie/server/controller/ChallengeController.java +++ b/server/src/main/java/com/seebie/server/controller/ChallengeController.java @@ -27,8 +27,8 @@ public ChallengeController(ChallengeService challengeService) { @PreAuthorize("hasRole('ROLE_ADMIN') || #publicId == authentication.principal.publicId") @PostMapping("/user/{publicId}/challenge") - public void createChallenge(@PathVariable UUID publicId, @Valid @RequestBody ChallengeDto challenge) { - challengeService.saveNew(publicId, challenge); + public ChallengeDetailDto createChallenge(@PathVariable UUID publicId, @Valid @RequestBody ChallengeDto challenge) { + return challengeService.saveNew(publicId, challenge); } @PreAuthorize("hasRole('ROLE_ADMIN') || #publicId == authentication.principal.publicId") From 0ef42ada3f1632760a2884cdad887eb2edc147b4 Mon Sep 17 00:00:00 2001 From: Jason Young Date: Sat, 12 Apr 2025 17:32:35 -0400 Subject: [PATCH 05/23] use setQueryData instead of invalidating all query data --- react/src/Chat.tsx | 2 +- react/src/CreateChallenge.tsx | 30 +++++++----------------------- react/src/utility/apiClient.ts | 7 ++++--- 3 files changed, 12 insertions(+), 27 deletions(-) diff --git a/react/src/Chat.tsx b/react/src/Chat.tsx index c7470d45..ad748617 100644 --- a/react/src/Chat.tsx +++ b/react/src/Chat.tsx @@ -35,7 +35,7 @@ function Chat() { }); const uploadMessageMutation = useMutation({ - mutationFn: (variables: PostVariables) => httpPost(variables.url, variables.body), + mutationFn: (variables: PostVariables) => httpPost(variables.url, variables.body), onSuccess: (message: MessageDto) => { setShowProcessingIcon(false); queryClient.setQueryData([chatUrl], (oldData: MessageDto[] | undefined) => [ diff --git a/react/src/CreateChallenge.tsx b/react/src/CreateChallenge.tsx index 58caa889..6647150a 100644 --- a/react/src/CreateChallenge.tsx +++ b/react/src/CreateChallenge.tsx @@ -11,7 +11,7 @@ import SuccessModal from "./component/SuccessModal"; import {toChallengeDto} from "./utility/Mapper"; import ChallengeForm from "./ChallengeForm"; import {useMutation, useQueryClient} from "@tanstack/react-query"; -import {ChallengeDto} from "./types/challenge.types.ts"; +import {ChallengeDetailDto, ChallengeDto} from "./types/challenge.types.ts"; import {httpPost, PostVariables} from "./utility/apiClient.ts"; import {useChallenges} from "./hooks/useChallenges.ts"; @@ -53,30 +53,14 @@ function CreateChallenge(props: {challengeUrl:string}) { const uploadNewChallenge = useMutation({ - mutationFn: (vars: PostVariables) => httpPost(vars.url, vars.body), - onSuccess: (challenge: ChallengeDto) => { - // the object returned from the server is passed to the onSuccess function - - console.log("Challenge created successfully"); + mutationFn: (vars: PostVariables) => httpPost(vars.url, vars.body), + onSuccess: (newlyCreatedChallenge: ChallengeDetailDto) => { setShowCreateSuccess(true); clearChallengeEdit(); - - // TODO update state when created, controller returned the created object - - // TODO does this need to be "| undefined"? - // See also Tools, same question - // queryClient.setQueryData([challengeUrl], (oldData: ChallengeDto[] | undefined) => [ - // ...(oldData ?? []), - // challenge, - // ]); - - // TODO if we do nothing with the promise does this still run? - // TODO should we use the refetchType where we use invalidateQueries elsewhere? - // what's the default refetchType? - queryClient.invalidateQueries({ - queryKey: [challengeUrl], - refetchType: 'all', - }).then(r =>{}); + queryClient.setQueryData([challengeUrl], (oldData: ChallengeDetailDto[]) => [ + ...(oldData ?? []), + newlyCreatedChallenge, + ]); }, }); diff --git a/react/src/utility/apiClient.ts b/react/src/utility/apiClient.ts index 27e0b685..412d40f2 100644 --- a/react/src/utility/apiClient.ts +++ b/react/src/utility/apiClient.ts @@ -29,11 +29,12 @@ const httpGet = async (url: string) => { return data as T; } -const httpPost = async (url: string, body: T) => { + +const httpPost = async (url: string, body: T) => { const requestMeta = buildRequestMeta('POST', JSON.stringify(body)); const response = await fetch(url, requestMeta); - const data = await response.json(); - return data as T; + const data = await response.json() as R; + return data as R; } const httpPut = async (url: string, body: Record) => { From 09ddc5b45196a4a7472f12a1d133f743f85a8b97 Mon Sep 17 00:00:00 2001 From: Jason Young Date: Tue, 15 Apr 2025 04:34:47 -0400 Subject: [PATCH 06/23] updated edit challenge, next use mutation for edit challenge. --- react/src/Challenge.tsx | 14 +++--- react/src/ChallengeForm.tsx | 15 +++--- react/src/EditChallenge.tsx | 79 ++++++++++++-------------------- react/src/hooks/useChallenges.ts | 4 +- react/src/utility/Mapper.ts | 10 +++- react/src/utility/apiClient.ts | 13 ++---- 6 files changed, 58 insertions(+), 77 deletions(-) diff --git a/react/src/Challenge.tsx b/react/src/Challenge.tsx index 0be83b37..495d9802 100644 --- a/react/src/Challenge.tsx +++ b/react/src/Challenge.tsx @@ -2,33 +2,31 @@ import React, {useState} from 'react'; import Container from "react-bootstrap/Container"; import {NavHeader} from "./App"; import {useParams} from "react-router-dom"; -import {GET} from "./utility/BasicHeaders"; import {Tab, Tabs} from "react-bootstrap"; import { emptyChallengeList} from "./utility/Constants"; import CollapsibleChallenge from "./component/CollapsibleChallenge"; import useApiDelete from "./hooks/useApiDelete"; import CreateChallenge from "./CreateChallenge"; -import {toChallengeList} from "./utility/Mapper"; -import {ChallengeData, ChallengeDetailDto, ChallengeList} from "./types/challenge.types"; -import {useQuery, useQueryClient} from "@tanstack/react-query"; +import {useQueryClient} from "@tanstack/react-query"; import {useChallenges} from "./hooks/useChallenges.ts"; function Challenge() { const {publicId} = useParams(); - const callDelete = useApiDelete(); // the user's current date is used to determine challenge completion status const challengeUrl = `/api/user/${publicId}/challenge`; + // TODO Replace useDelete with TSQ mutation, update state when deleted + // try httpDelete(), compare with useApiDelete() + // can we selectively remove from the TSQ cache? or should we invalidate the whole challenge cache? - // TODO Replace useDelete with TSQ mutation - // update state when deleted const queryClient = useQueryClient(); const [deletedCount, setDeletedCount] = useState(0); + const callDelete = useApiDelete(); const deleteChallenge = (challengeId: number) => { - const endpoint = `/api/user/${publicId}/challenge/${challengeId}`; + const endpoint = `${challengeUrl}/${challengeId}`; callDelete(endpoint).then(() => setDeletedCount(deletedCount + 1)); } diff --git a/react/src/ChallengeForm.tsx b/react/src/ChallengeForm.tsx index ffd62146..91e3bde2 100644 --- a/react/src/ChallengeForm.tsx +++ b/react/src/ChallengeForm.tsx @@ -8,7 +8,7 @@ import {faExclamationTriangle} from "@fortawesome/free-solid-svg-icons"; import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; import {ChallengeData, ChallengeList} from "./types/challenge.types"; -import {localDateToJsDate, jsDateToLocalDate} from "./utility/Mapper.ts"; +import {localDateToJsDate, jsDateToLocalDate, flatten} from "./utility/Mapper.ts"; const overlaps = (c1: ChallengeData, c2: ChallengeData): boolean => { @@ -27,6 +27,11 @@ function ChallengeForm(props:{ const {setEditableChallenge, editableChallenge, setDataValid, savedChallenges} = props; + let flattenedChallenges = flatten(savedChallenges); + // If editing, we don't want it to conflict with its own name or dates + // so exclude challenge based on id, and for a new challenge the id is 0 + flattenedChallenges = flattenedChallenges.filter(details => details.id !== editableChallenge.id); + // validation of individual fields for validation feedback to the user const [dateOrderValid, setDateOrderValid] = useState(true); const [nameValid, setNameValid] = useState(true); @@ -46,12 +51,6 @@ function ChallengeForm(props:{ ? (challenge.name !== '' && challenge.name.trim() === challenge.name) : true; - - const flattenedChallenges = [] as ChallengeData[]; - flattenedChallenges.push(...savedChallenges.completed); - flattenedChallenges.push(...savedChallenges.current); - flattenedChallenges.push(...savedChallenges.upcoming); - const dateOrderValid = challenge.start.isBefore(challenge.finish); const nameUnique = !flattenedChallenges.some(saved => saved.name === challenge.name); const datesOverlap = flattenedChallenges.some(saved => overlaps(challenge, saved) ); @@ -143,7 +142,7 @@ function ChallengeForm(props:{ ); diff --git a/react/src/EditChallenge.tsx b/react/src/EditChallenge.tsx index 18d345ce..e1c7c6dd 100644 --- a/react/src/EditChallenge.tsx +++ b/react/src/EditChallenge.tsx @@ -1,33 +1,28 @@ -import React, {useEffect, useState} from 'react'; +import React, {useState} from 'react'; import Container from "react-bootstrap/Container"; import useApiPut from "./hooks/useApiPut"; -import {GET} from "./utility/BasicHeaders"; import Button from "react-bootstrap/Button"; import useApiDelete from "./hooks/useApiDelete"; import {NavHeader} from "./App"; import {useNavigate, useParams} from "react-router-dom"; import WarningButton from "./component/WarningButton"; import ChallengeForm from "./ChallengeForm"; -import {emptyChallengeList, emptyEditableChallenge} from "./utility/Constants"; -import { - toLocalChallengeData, - toChallengeDto, - toChallengeDetailDto, - toChallengeList -} from "./utility/Mapper"; -import {ChallengeDetailDto, ChallengeDto} from "./types/challenge.types"; - -const removeChallengesWithId = (challengeList: ChallengeDetailDto[], challengeId: number) => { - return challengeList.filter(details => details.id !== challengeId); -} +import {emptyChallengeList} from "./utility/Constants"; +import {flatten, toChallengeDto,} from "./utility/Mapper"; +import {useChallenges} from "./hooks/useChallenges.ts"; +function ensure(argument: T | undefined | null, message: string = 'This value was promised to be there.'): T { + if (argument === undefined || argument === null) { + throw new TypeError(message); + } + return argument; +} function EditChallenge() { - const navigate = useNavigate(); - const {publicId, challengeId} = useParams(); + const navigate = useNavigate(); if (challengeId === undefined) { throw new Error("Challenge ID is required."); @@ -35,43 +30,31 @@ function EditChallenge() { const numericChallengeId = parseInt(challengeId); - const allChallengesEndpoint = `/api/user/${publicId}/challenge`; - const challengeEndpoint = `/api/user/${publicId}/challenge/${challengeId}`; + const challengeUrl = `/api/user/${publicId}/challenge`; + const editChallengeUrl = `${challengeUrl}/${challengeId}`; - const [loaded, setLoaded] = useState(false); - const [editableChallenge, setEditableChallenge] = useState(emptyEditableChallenge()); const [dataValid, setDataValid] = useState(true); - const [savedChallenges, setSavedChallenges] = useState(emptyChallengeList); - const put = useApiPut(); - const callDelete = useApiDelete(); + // TODO pass in the TSQ key challenge url instead of the list + const { data: savedChallenges = emptyChallengeList } = useChallenges(challengeUrl); + const allChallenges = flatten(savedChallenges); + const maybeChallenge = allChallenges.find(challenge => challenge.id === numericChallengeId); + const existingChallenge = ensure(maybeChallenge); + + const [editableChallenge, setEditableChallenge] = useState(existingChallenge); + // TODO try httpPut, httpDelete + // use mutation, can we selectively update the cache? - useEffect(() => { - fetch(challengeEndpoint, GET) - .then(response => response.json() as Promise) - .then(challenge => toChallengeDetailDto(challenge, numericChallengeId)) - .then(toLocalChallengeData) - .then(setEditableChallenge) - .then(() => setLoaded(true)) - }, [setEditableChallenge, challengeEndpoint]); - - // load all challenges to check for validation - useEffect(() => { - fetch(allChallengesEndpoint, GET) - .then((response) => response.json() as Promise) - .then(challengeList => removeChallengesWithId(challengeList, numericChallengeId)) - .then(toChallengeList) - .then(setSavedChallenges) - .catch(error => console.log(error)); - }, [allChallengesEndpoint]); + const put = useApiPut(); + const callDelete = useApiDelete(); const onSave = () => { - put(challengeEndpoint, toChallengeDto(editableChallenge)).then(() => navigate(-1)); + put(editChallengeUrl, toChallengeDto(editableChallenge)).then(() => navigate(-1)); } const deleteById = () => { - callDelete(challengeEndpoint).then(() => navigate(-1)); + callDelete(editChallengeUrl).then(() => navigate(-1)); } return ( @@ -84,12 +67,10 @@ function EditChallenge() { - {loaded - ? - :
} +
diff --git a/react/src/hooks/useChallenges.ts b/react/src/hooks/useChallenges.ts index 42cfcf59..f2532b98 100644 --- a/react/src/hooks/useChallenges.ts +++ b/react/src/hooks/useChallenges.ts @@ -1,7 +1,6 @@ // This custom hook is used to fetch challenge data from an API endpoint and // transform it into a format suitable for the UI. By encapsulating all query -// logic—including the data fetching, caching, and mapping—in this hook, we ensure -// that every component that consumes this data gets the same, consistent output. +// logic in this hook, we ensure that every component that consumes this data gets the same output. // It also prevents duplication of code and minimizes the risk of divergence if // the query configuration ever needs to change. @@ -15,7 +14,6 @@ import {GET} from "../utility/BasicHeaders.ts"; // The queryKey uses the challengeUrl to ensure the query is uniquely identified. export const useChallenges = (challengeUrl: string) => { - // TODO should this be httpGet()? // and should we use httpGet in Tools? diff --git a/react/src/utility/Mapper.ts b/react/src/utility/Mapper.ts index 357a740b..e90c074e 100644 --- a/react/src/utility/Mapper.ts +++ b/react/src/utility/Mapper.ts @@ -72,6 +72,14 @@ const toChallengeList = (challengeList: ChallengeDetailDto[]): ChallengeList) => { + let flattenedChallenges : ChallengeData[] = []; + flattenedChallenges.push(...list.completed); + flattenedChallenges.push(...list.current); + flattenedChallenges.push(...list.upcoming); + return flattenedChallenges; +} + const toChallengeDetailDto = (dto: ChallengeDto, id: number): ChallengeDetailDto => { return { id: id, @@ -125,7 +133,7 @@ const toSleepDto = (sleep: SleepData): SleepDto => { } export { - toSelectableChallenges, toChallengeDto, toLocalChallengeData, toChallengeDetailDto, + flatten, toSelectableChallenges, toChallengeDto, toLocalChallengeData, toChallengeDetailDto, toLocalSleepData, toSleepDto, calculateProgress, toChallengeList, jsDateToLocalDate, localDateToJsDate, jsDateToLocalDateTime, localDateTimeToJsDate, localDateToString } diff --git a/react/src/utility/apiClient.ts b/react/src/utility/apiClient.ts index 412d40f2..0317048c 100644 --- a/react/src/utility/apiClient.ts +++ b/react/src/utility/apiClient.ts @@ -23,25 +23,22 @@ function buildRequestMeta(method:HttpMethod='GET', body:string=''):RequestInit { return { headers, method }; } -const httpGet = async (url: string) => { +const httpGet = async (url: string) => { const response = await fetch(url, buildRequestMeta()); - const data = await response.json(); - return data as T; + return await response.json() as R; } const httpPost = async (url: string, body: T) => { const requestMeta = buildRequestMeta('POST', JSON.stringify(body)); const response = await fetch(url, requestMeta); - const data = await response.json() as R; - return data as R; + return await response.json() as R } -const httpPut = async (url: string, body: Record) => { +const httpPut = async (url: string, body: T) => { const requestMeta = buildRequestMeta('PUT', JSON.stringify(body)); const response = await fetch(url, requestMeta); - const data = await response.json(); - return data as T; + return await response.json() as R; } const httpDelete = (url: string) => { From 60eb30e7937cd689e1837b211699f70fcaa9845e Mon Sep 17 00:00:00 2001 From: Jason Young Date: Tue, 15 Apr 2025 05:10:21 -0400 Subject: [PATCH 07/23] use mutation for edit challenge --- react/src/Chat.tsx | 4 +-- react/src/CreateChallenge.tsx | 13 ++++---- react/src/EditChallenge.tsx | 30 +++++++++++++++---- react/src/utility/apiClient.ts | 8 ++--- .../controller/ChallengeController.java | 4 +-- .../mapper/entitytodto/ChallengeMapper.java | 14 +++++++++ .../server/service/ChallengeService.java | 8 +++-- 7 files changed, 58 insertions(+), 23 deletions(-) create mode 100644 server/src/main/java/com/seebie/server/mapper/entitytodto/ChallengeMapper.java diff --git a/react/src/Chat.tsx b/react/src/Chat.tsx index ad748617..85e30c1f 100644 --- a/react/src/Chat.tsx +++ b/react/src/Chat.tsx @@ -2,7 +2,7 @@ import React, {useCallback, useEffect, useRef, useState} from 'react'; import Container from "react-bootstrap/Container"; import {NavHeader} from "./App"; import {useParams} from "react-router-dom"; -import {httpDelete, httpGet, httpPost, PostVariables} from "./utility/apiClient.ts"; +import {httpDelete, httpGet, httpPost, UploadVars} from "./utility/apiClient.ts"; import Row from "react-bootstrap/Row"; import Col from "react-bootstrap/Col"; import {MessageDto, MessageType} from "./types/message.types.ts"; @@ -35,7 +35,7 @@ function Chat() { }); const uploadMessageMutation = useMutation({ - mutationFn: (variables: PostVariables) => httpPost(variables.url, variables.body), + mutationFn: (variables: UploadVars) => httpPost(variables.url, variables.body), onSuccess: (message: MessageDto) => { setShowProcessingIcon(false); queryClient.setQueryData([chatUrl], (oldData: MessageDto[] | undefined) => [ diff --git a/react/src/CreateChallenge.tsx b/react/src/CreateChallenge.tsx index 6647150a..9b012e7d 100644 --- a/react/src/CreateChallenge.tsx +++ b/react/src/CreateChallenge.tsx @@ -12,7 +12,7 @@ import {toChallengeDto} from "./utility/Mapper"; import ChallengeForm from "./ChallengeForm"; import {useMutation, useQueryClient} from "@tanstack/react-query"; import {ChallengeDetailDto, ChallengeDto} from "./types/challenge.types.ts"; -import {httpPost, PostVariables} from "./utility/apiClient.ts"; +import {httpPost, UploadVars} from "./utility/apiClient.ts"; import {useChallenges} from "./hooks/useChallenges.ts"; function CreateChallenge(props: {challengeUrl:string}) { @@ -53,15 +53,14 @@ function CreateChallenge(props: {challengeUrl:string}) { const uploadNewChallenge = useMutation({ - mutationFn: (vars: PostVariables) => httpPost(vars.url, vars.body), + mutationFn: (vars: UploadVars) => httpPost(vars.url, vars.body), onSuccess: (newlyCreatedChallenge: ChallengeDetailDto) => { setShowCreateSuccess(true); clearChallengeEdit(); - queryClient.setQueryData([challengeUrl], (oldData: ChallengeDetailDto[]) => [ - ...(oldData ?? []), - newlyCreatedChallenge, - ]); - }, + queryClient.setQueryData([challengeUrl], (oldData: ChallengeDetailDto[]) => { + return [ ...(oldData ?? []), newlyCreatedChallenge ]; + }); + } }); const saveEditableChallenge = () => { diff --git a/react/src/EditChallenge.tsx b/react/src/EditChallenge.tsx index e1c7c6dd..13448fba 100644 --- a/react/src/EditChallenge.tsx +++ b/react/src/EditChallenge.tsx @@ -1,7 +1,6 @@ import React, {useState} from 'react'; import Container from "react-bootstrap/Container"; -import useApiPut from "./hooks/useApiPut"; import Button from "react-bootstrap/Button"; import useApiDelete from "./hooks/useApiDelete"; import {NavHeader} from "./App"; @@ -11,6 +10,9 @@ import ChallengeForm from "./ChallengeForm"; import {emptyChallengeList} from "./utility/Constants"; import {flatten, toChallengeDto,} from "./utility/Mapper"; import {useChallenges} from "./hooks/useChallenges.ts"; +import {useMutation, useQueryClient} from "@tanstack/react-query"; +import {httpPut, UploadVars} from "./utility/apiClient.ts"; +import {ChallengeDetailDto, ChallengeDto} from "./types/challenge.types.ts"; function ensure(argument: T | undefined | null, message: string = 'This value was promised to be there.'): T { if (argument === undefined || argument === null) { @@ -43,16 +45,32 @@ function EditChallenge() { const [editableChallenge, setEditableChallenge] = useState(existingChallenge); - // TODO try httpPut, httpDelete - // use mutation, can we selectively update the cache? - const put = useApiPut(); - const callDelete = useApiDelete(); + const queryClient = useQueryClient(); + + const updateChallenge = useMutation({ + mutationFn: (vars: UploadVars) => httpPut(vars.url, vars.body), + onSuccess: (updatedChallenge: ChallengeDetailDto) => { + queryClient.setQueryData([challengeUrl], (oldData: ChallengeDetailDto[]) => { + const updatedList = oldData.filter(challenge => challenge.id !== updatedChallenge.id); + return [ ...(updatedList ?? []), updatedChallenge ] + }); + + // TODO what if the user navigated here directly and there is no previous url? + navigate(-1); + }, + }); const onSave = () => { - put(editChallengeUrl, toChallengeDto(editableChallenge)).then(() => navigate(-1)); + updateChallenge.mutate({ + url: editChallengeUrl, + body: toChallengeDto(editableChallenge) + }); } + // TODO try httpDelete + const callDelete = useApiDelete(); + const deleteById = () => { callDelete(editChallengeUrl).then(() => navigate(-1)); } diff --git a/react/src/utility/apiClient.ts b/react/src/utility/apiClient.ts index 0317048c..07acef39 100644 --- a/react/src/utility/apiClient.ts +++ b/react/src/utility/apiClient.ts @@ -1,5 +1,5 @@ -interface PostVariables { +interface UploadVars { url: string; body: T; } @@ -38,12 +38,12 @@ const httpPost = async (url: string, body: T) => { const httpPut = async (url: string, body: T) => { const requestMeta = buildRequestMeta('PUT', JSON.stringify(body)); const response = await fetch(url, requestMeta); - return await response.json() as R; + return await response.json() as R } const httpDelete = (url: string) => { return fetch(url, buildRequestMeta('DELETE')); } -export type {PostVariables} -export {httpGet, httpPost, httpDelete} +export type {UploadVars} +export {httpGet, httpPut, httpPost, httpDelete} diff --git a/server/src/main/java/com/seebie/server/controller/ChallengeController.java b/server/src/main/java/com/seebie/server/controller/ChallengeController.java index 07f57474..d5dae8d4 100644 --- a/server/src/main/java/com/seebie/server/controller/ChallengeController.java +++ b/server/src/main/java/com/seebie/server/controller/ChallengeController.java @@ -33,8 +33,8 @@ public ChallengeDetailDto createChallenge(@PathVariable UUID publicId, @Valid @R @PreAuthorize("hasRole('ROLE_ADMIN') || #publicId == authentication.principal.publicId") @PutMapping("/user/{publicId}/challenge/{challengeId}") - public void updateChallenge(@Valid @RequestBody ChallengeDto challengeData, @PathVariable UUID publicId, @PathVariable Long challengeId) { - challengeService.update(publicId, challengeId, challengeData); + public ChallengeDetailDto updateChallenge(@Valid @RequestBody ChallengeDto challengeData, @PathVariable UUID publicId, @PathVariable Long challengeId) { + return challengeService.update(publicId, challengeId, challengeData); } @PreAuthorize("hasRole('ROLE_ADMIN') || #publicId == authentication.principal.publicId") diff --git a/server/src/main/java/com/seebie/server/mapper/entitytodto/ChallengeMapper.java b/server/src/main/java/com/seebie/server/mapper/entitytodto/ChallengeMapper.java new file mode 100644 index 00000000..b4d972f1 --- /dev/null +++ b/server/src/main/java/com/seebie/server/mapper/entitytodto/ChallengeMapper.java @@ -0,0 +1,14 @@ +package com.seebie.server.mapper.entitytodto; + +import com.seebie.server.dto.ChallengeDetailDto; +import com.seebie.server.entity.Challenge; + +import java.util.function.Function; + +public class ChallengeMapper implements Function { + + @Override + public ChallengeDetailDto apply(Challenge entity) { + return new ChallengeDetailDto(entity.getId(), entity.getName(), entity.getDescription(), entity.getStart(), entity.getFinish()); + } +} diff --git a/server/src/main/java/com/seebie/server/service/ChallengeService.java b/server/src/main/java/com/seebie/server/service/ChallengeService.java index 781971ec..958bed30 100644 --- a/server/src/main/java/com/seebie/server/service/ChallengeService.java +++ b/server/src/main/java/com/seebie/server/service/ChallengeService.java @@ -3,6 +3,7 @@ import com.seebie.server.dto.ChallengeDto; import com.seebie.server.dto.ChallengeDetailDto; import com.seebie.server.mapper.dtotoentity.UnsavedChallengeListMapper; +import com.seebie.server.mapper.entitytodto.ChallengeMapper; import com.seebie.server.repository.ChallengeRepository; import jakarta.persistence.EntityNotFoundException; import org.springframework.http.HttpStatus; @@ -18,6 +19,7 @@ public class ChallengeService { private ChallengeRepository challengeRepo; private UnsavedChallengeListMapper toEntity; + private ChallengeMapper toDto = new ChallengeMapper(); public ChallengeService(ChallengeRepository challengeRepo, UnsavedChallengeListMapper toEntity) { this.challengeRepo = challengeRepo; @@ -30,16 +32,18 @@ public ChallengeDetailDto saveNew(UUID publicId, ChallengeDto challenge) { // The computed value for timeAsleep isn't calculated until the transaction is closed // so the entity does not have the correct value here. var entity = challengeRepo.save(toEntity.toUnsavedEntity(publicId, challenge)); - return new ChallengeDetailDto(entity.getId(), entity.getName(), entity.getDescription(), entity.getStart(), entity.getFinish()); + return toDto.apply(entity); } @Transactional - public void update(UUID publicId, Long challengeId, ChallengeDto dto) { + public ChallengeDetailDto update(UUID publicId, Long challengeId, ChallengeDto dto) { var entity = challengeRepo.findByUser(publicId, challengeId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Challenge not found")); entity.setChallengeData(dto.name(), dto.description(), dto.start(), dto.finish()); + + return toDto.apply(entity); } @Transactional(readOnly = true) From b5d85c4bf8320109461893c506d785297d82a139 Mon Sep 17 00:00:00 2001 From: Jason Young Date: Tue, 15 Apr 2025 05:15:27 -0400 Subject: [PATCH 08/23] remove comment --- react/src/EditChallenge.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/react/src/EditChallenge.tsx b/react/src/EditChallenge.tsx index 13448fba..fcfed4d1 100644 --- a/react/src/EditChallenge.tsx +++ b/react/src/EditChallenge.tsx @@ -56,7 +56,6 @@ function EditChallenge() { return [ ...(updatedList ?? []), updatedChallenge ] }); - // TODO what if the user navigated here directly and there is no previous url? navigate(-1); }, }); From e78decce2a0c2f1814f0dfee03fb6f3f35cfc67b Mon Sep 17 00:00:00 2001 From: Jason Young Date: Sun, 27 Apr 2025 06:03:42 -0400 Subject: [PATCH 09/23] editing and saving works --- react/src/App.tsx | 32 ++++--- react/src/Challenge.tsx | 1 + react/src/ChallengeFormTSQ.tsx | 161 +++++++++++++++++++++++++++++++++ react/src/Chat.tsx | 5 +- react/src/EditChallenge.tsx | 117 ++++++++++++++---------- react/src/index.tsx | 6 -- 6 files changed, 250 insertions(+), 72 deletions(-) create mode 100644 react/src/ChallengeFormTSQ.tsx diff --git a/react/src/App.tsx b/react/src/App.tsx index 7ec178d5..508f8a93 100644 --- a/react/src/App.tsx +++ b/react/src/App.tsx @@ -1,4 +1,4 @@ -import React, {useState} from 'react'; +import React, {Suspense, useState} from 'react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; @@ -148,20 +148,22 @@ function AuthenticatedApp() { - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + Loading...
}> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + diff --git a/react/src/Challenge.tsx b/react/src/Challenge.tsx index 495d9802..0c9f0f2c 100644 --- a/react/src/Challenge.tsx +++ b/react/src/Challenge.tsx @@ -23,6 +23,7 @@ function Challenge() { // can we selectively remove from the TSQ cache? or should we invalidate the whole challenge cache? const queryClient = useQueryClient(); + const [deletedCount, setDeletedCount] = useState(0); const callDelete = useApiDelete(); const deleteChallenge = (challengeId: number) => { diff --git a/react/src/ChallengeFormTSQ.tsx b/react/src/ChallengeFormTSQ.tsx new file mode 100644 index 00000000..345e248b --- /dev/null +++ b/react/src/ChallengeFormTSQ.tsx @@ -0,0 +1,161 @@ +import React, {useEffect, useState} from 'react'; +import 'react-datepicker/dist/react-datepicker.css'; +import Container from "react-bootstrap/Container"; +import Form from "react-bootstrap/Form"; +import DatePicker from "react-datepicker"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faExclamationTriangle} from "@fortawesome/free-solid-svg-icons"; +import Row from "react-bootstrap/Row"; +import Col from "react-bootstrap/Col"; +import {ChallengeData} from "./types/challenge.types"; +import {localDateToJsDate, jsDateToLocalDate} from "./utility/Mapper.ts"; + + +const overlaps = (c1: ChallengeData, c2: ChallengeData): boolean => { + // Two intervals [c1.start, c1.finish] and [c2.start, c2.finish] overlap if and only if they are not disjoint. + // Intervals are disjoint if c1.finish < c2.start or c1.start > c2.finish. + return ! ( c1.finish.isBefore(c2.start) || c1.start.isAfter(c2.finish) ); +}; + +function ChallengeFormTSQ(props:{ + savedChallenges:ChallengeData[] + draftChallenge:ChallengeData, + onValidityChanged: (valid: boolean) => void, + onChallengeChanged: (latestDraft: ChallengeData) => void, + }) { + + + const {savedChallenges, draftChallenge, onValidityChanged, onChallengeChanged} = props; + + + // validation of individual fields for validation feedback to the user + const [dateOrderValid, setDateOrderValid] = useState(true); + const [nameValid, setNameValid] = useState(true); + const [nameUnique, setNameUnique] = useState(true); + + // this is a warning, so we don't disable the save button + const [datesOverlap, setDatesOverlap] = useState(false); + + // Add a state variable to track user interaction with the name field + const [nameTouched, setNameTouched] = useState(false); + + + // TODO check saving dates are preserved + + // TODO do this later? + // navigate doesn't work if direct page loading, + // should navigate to challenge home after save or cancel (can we check if navigate(-1) is available?) + + const validateChallenge = (challenge: ChallengeData) => { + + // name validation to consider if the user has interacted with the field + const newNameValid = nameTouched + ? (challenge.name !== '' && challenge.name.trim() === challenge.name) + : true; + + + const newDateOrderValid = challenge.start.isBefore(challenge.finish); + const newNameUnique = !savedChallenges.some(saved => saved.name === challenge.name); + const newDatesOverlap = savedChallenges.some(saved => overlaps(challenge, saved) ); + + const previousValidity = dateOrderValid && nameValid && nameUnique; + const newValidity = newDateOrderValid && newNameUnique && newNameValid; + + setNameValid(newNameValid); + setDateOrderValid(newDateOrderValid); + setNameUnique(newNameUnique); + setDatesOverlap(newDatesOverlap); + + if(previousValidity != newValidity) { + onValidityChanged(newValidity); + } + }; + + + // useEffect to run validation on component mount and whenever editableChallenge changes + // This is so the validation is run when the form is populated from outside + // (e.g. when the user selects a predefined challenge) + // and not just when the user edits the form + useEffect(() => { + validateChallenge(draftChallenge); + }, [draftChallenge]); + + const updateChallenge = (updateValues: Partial ) => { + const updatedChallenge:ChallengeData = {...draftChallenge, ...updateValues}; + validateChallenge(updatedChallenge); + onChallengeChanged(updatedChallenge); + } + + + return ( + <> +
+ + + + This name is already used + + { + setNameTouched(true); + updateChallenge({name: e.target.value})} + } + isInvalid={!nameValid || !nameUnique} + /> + + + Can't be empty or have space at the ends + + +