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 0ce9f6ff..2fb8fa94 100644 --- a/react/src/Challenge.tsx +++ b/react/src/Challenge.tsx @@ -1,72 +1,67 @@ -import React, {useEffect, useState} from 'react'; +import React 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 {ChallengeDetailDto} from "./types/challenge.types"; +import {ChallengeDetailDto, ChallengeList} from "./types/challenge.types.ts"; +import {useMutation, useQueryClient, useSuspenseQuery} from "@tanstack/react-query"; +import {toChallengeList} from "./utility/Mapper.ts"; +import {httpDelete, httpGet} from "./utility/apiClient.ts"; function Challenge() { const {publicId} = useParams(); - 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); + const queryClient = useQueryClient(); - useEffect(() => { - fetch(challengeEndpoint, GET) - .then((response) => response.json() as Promise) - .then(toChallengeList) - .then(setSavedChallenges) - .catch(error => console.log(error)); - }, [createdCount, deletedCount]); + const deleteMutation = useMutation({ + mutationFn: (url: string) => httpDelete(url), + onSuccess: () => queryClient.invalidateQueries({queryKey: [challengeUrl]}) + }); const deleteChallenge = (challengeId: number) => { - const endpoint = `/api/user/${publicId}/challenge/${challengeId}`; - callDelete(endpoint).then(() => setDeletedCount(deletedCount + 1)); + deleteMutation.mutate(`${challengeUrl}/${challengeId}`); } + const {data: savedChallenges} = useSuspenseQuery({ + queryKey: [challengeUrl], + queryFn: () => httpGet(challengeUrl), + select: toChallengeList + }); + return ( - setCreatedCount(createdCount+1)} - savedChallenges={[...savedChallenges.completed, ...savedChallenges.current, ...savedChallenges.upcoming]} - /> + - + - {savedChallenges.current.map((challenge, index) => - + deleteChallenge(challenge.id)} /> )} - {savedChallenges.completed.map((challenge, index) => - + deleteChallenge(challenge.id)} /> )} - {savedChallenges.upcoming.map((challenge, index) => - + deleteChallenge(challenge.id)} /> )} diff --git a/react/src/ChallengeForm.tsx b/react/src/ChallengeForm.tsx index 6c422ae3..1c7d7a1b 100644 --- a/react/src/ChallengeForm.tsx +++ b/react/src/ChallengeForm.tsx @@ -7,8 +7,10 @@ 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"; +import {ChallengeData, ChallengeDetailDto} from "./types/challenge.types"; +import {localDateToJsDate, jsDateToLocalDate, toLocalChallengeData} from "./utility/Mapper.ts"; +import {useSuspenseQuery} from "@tanstack/react-query"; +import {httpGet} from "./utility/apiClient.ts"; const overlaps = (c1: ChallengeData, c2: ChallengeData): boolean => { @@ -17,58 +19,67 @@ const overlaps = (c1: ChallengeData, c2: ChallengeData): boolean => { return ! ( c1.finish.isBefore(c2.start) || c1.start.isAfter(c2.finish) ); }; -function ChallengeForm(props:{ - setEditableChallenge:React.Dispatch> - editableChallenge:ChallengeData, - setDataValid:React.Dispatch>, - savedChallenges:ChallengeData[]} - ) { +interface ChallengeFormProps { + challengeUrl:string, + draftChallenge:ChallengeData, + onValidityChanged: (valid: boolean) => void, + onChallengeChanged: (latestDraft: ChallengeData) => void, +} - const {setEditableChallenge, editableChallenge, setDataValid, savedChallenges} = props; +function ChallengeForm(props:ChallengeFormProps) { - // 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); + const {challengeUrl, draftChallenge, onValidityChanged, onChallengeChanged} = props; + + // select affects the returned data value but does not affect what gets stored in the query cache + // filter out the current challenge (a new challenge will have id 0 and not remove anything) + const {data: validationChallenges} = useSuspenseQuery({ + queryKey: [challengeUrl], + queryFn: () => httpGet(challengeUrl), + select: data => data + .filter(challenge => challenge.id !== draftChallenge.id) + .map(challenge => toLocalChallengeData(challenge)) + }); // 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); - + // validation of individual fields for validation feedback to the user + const [dateOrderValid, setDateOrderValid] = useState(true); + const [nameSpacesValid, setNameSpacesValid] = useState(true); + const [nameUnique, setNameUnique] = useState(true); const validateChallenge = (challenge: ChallengeData) => { - // name validation to consider if the user has interacted with the field - const nameValid = nameTouched - ? (challenge.name !== '' && challenge.name.trim() === challenge.name) - : true; + // warnings + const newDatesOverlap = validationChallenges.some(saved => overlaps(challenge, saved) ); + setDatesOverlap(newDatesOverlap); + // errors + const newNameSpacesValid = (challenge.name !== '' && challenge.name.trim() === challenge.name); + const newNameUnique = !validationChallenges.some(saved => saved.name === challenge.name); + const newDateOrderValid = challenge.start.isBefore(challenge.finish); - 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 newValidity = newDateOrderValid && newNameSpacesValid && newNameUnique; - setDateOrderValid(dateOrderValid); - setNameValid(nameValid); - setNameUnique(nameUnique); - setDatesOverlap(datesOverlap); - setDataValid(dateOrderValid && nameValid && nameUnique); + setNameSpacesValid(newNameSpacesValid); + setDateOrderValid(newDateOrderValid); + setNameUnique(newNameUnique); + + onValidityChanged(newValidity); }; - // useEffect to run validation on component mount and whenever editableChallenge changes + // useEffect to run validation on component mount and whenever draft 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(editableChallenge); - }, [editableChallenge]); + validateChallenge(draftChallenge); + }, [draftChallenge]); const updateChallenge = (updateValues: Partial ) => { - const updatedChallenge:ChallengeData = {...editableChallenge, ...updateValues}; - setEditableChallenge(updatedChallenge); + const updatedChallenge:ChallengeData = {...draftChallenge, ...updateValues}; validateChallenge(updatedChallenge); + onChallengeChanged(updatedChallenge); } @@ -86,22 +97,22 @@ function ChallengeForm(props:{ className="form-control" id="challengeName" placeholder="" - value={editableChallenge.name} - onChange={e => { - setNameTouched(true); - updateChallenge({name: e.target.value})} - } - isInvalid={!nameValid || !nameUnique} - /> + value={draftChallenge.name} + onChange={e => updateChallenge({name: e.target.value})} + isInvalid={!nameSpacesValid || !nameUnique} /> + className={"mh-24px d-block " + (!nameSpacesValid ? 'visible' : 'invisible')}> Can't be empty or have space at the ends -