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 - updateChallenge({description: e.target.value})}/> + updateChallenge({description: e.target.value})} /> @@ -113,7 +124,7 @@ function ChallengeForm(props:{ {if(date) updateChallenge({start: jsDateToLocalDate(date)})}} - selected={localDateToJsDate(editableChallenge.start)}/> + selected={localDateToJsDate(draftChallenge.start)}/> @@ -122,9 +133,9 @@ function ChallengeForm(props:{ {if(date) updateChallenge({finish: jsDateToLocalDate(date)})}} - selected={localDateToJsDate(editableChallenge.finish)}/> + selected={localDateToJsDate(draftChallenge.finish)}/> @@ -137,7 +148,7 @@ function ChallengeForm(props:{ - This date range overlaps another challenge which is not recommended + This date range overlaps another challenge > ); diff --git a/react/src/Chat.tsx b/react/src/Chat.tsx index 988ba4aa..960f181c 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"; @@ -16,9 +16,6 @@ import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query"; function Chat() { const {publicId} = useParams(); - if (publicId === undefined) { - throw new Error("Public ID is required in the url"); - } const chatUrl = `/api/user/${publicId}/chat` @@ -35,10 +32,10 @@ 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) => [ + queryClient.setQueryData([chatUrl], (oldData: MessageDto[]) => [ ...(oldData ?? []), message, ]); @@ -51,7 +48,7 @@ function Chat() { const userPrompt: MessageDto = { content: prompt.trim(), type: MessageType.USER }; - queryClient.setQueryData([chatUrl], (oldData: MessageDto[] | undefined) => [ + queryClient.setQueryData([chatUrl], (oldData: MessageDto[]) => [ ...(oldData ?? []), userPrompt, ]); @@ -87,8 +84,9 @@ function Chat() { + titleText={"Chat About Sleep"} + modalText={"Talk with an AI counsellor about how to improve your sleep. " + + "Chat history is only available for the last 7 days"} /> This deletes the entire conversation and cannot be undone. Proceed? diff --git a/react/src/CreateChallenge.tsx b/react/src/CreateChallenge.tsx index 6557961c..e40e0ce3 100644 --- a/react/src/CreateChallenge.tsx +++ b/react/src/CreateChallenge.tsx @@ -3,54 +3,42 @@ 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 {ChallengeDetailDto, ChallengeDto} from "./types/challenge.types.ts"; +import {httpPost, UploadVars} from "./utility/apiClient.ts"; +import ChallengeForm from "./ChallengeForm.tsx"; -function CreateChallenge(props: {onCreated:()=>void, savedChallenges:ChallengeData[]}) { +interface CreateChallengeProps { challengeUrl: string } - const {onCreated, savedChallenges} = props; +function CreateChallenge(props:CreateChallengeProps) { - const {publicId} = useParams(); - - const challengeEndpoint = `/api/user/${publicId}/challenge`; + const {challengeUrl} = props; const [showCreateSuccess, setShowCreateSuccess] = useState(false); const [showCreateChallenge, setShowCreateChallenge] = useState(false); const [showPredefinedChallenges, setShowPredefinedChallenges] = useState(false); - const [editableChallenge, setEditableChallenge] = useState(emptyEditableChallenge()); + const [draftChallenge, setDraftChallenge] = useState(emptyEditableChallenge()); // validation of the overall form, so we know whether to enable the save button - // 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 [dataValid, setDataValid] = useState(true); const clearChallengeEdit = () => { setShowCreateChallenge(false); - setEditableChallenge(emptyEditableChallenge()); + setDraftChallenge(emptyEditableChallenge()); setDataValid(true); } const onSelectChallenge = (selectedChallenge: NameDescription) => { - let updatedChallenge = {...editableChallenge}; + let updatedChallenge = {...draftChallenge}; updatedChallenge.name = selectedChallenge.name; updatedChallenge.description = selectedChallenge.description; - setEditableChallenge(updatedChallenge); + setDraftChallenge(updatedChallenge); swapModals(); } @@ -59,6 +47,26 @@ function CreateChallenge(props: {onCreated:()=>void, savedChallenges:ChallengeDa setShowPredefinedChallenges( ! showPredefinedChallenges); } + const queryClient = useQueryClient(); + + const uploadNewChallenge = useMutation({ + mutationFn: (vars: UploadVars) => httpPost(vars.url, vars.body), + onSuccess: (newlyCreatedChallenge: ChallengeDetailDto) => { + setShowCreateSuccess(true); + clearChallengeEdit(); + queryClient.setQueryData([challengeUrl], (oldData: ChallengeDetailDto[]) => { + return [ ...(oldData ?? []), newlyCreatedChallenge ]; + }); + } + }); + + const saveEditableChallenge = () => { + uploadNewChallenge.mutate({ + url: challengeUrl, + body: toChallengeDto(draftChallenge) + }); + }; + return ( <> setShowCreateChallenge(true)}> @@ -73,14 +81,14 @@ function CreateChallenge(props: {onCreated:()=>void, savedChallenges:ChallengeDa Select from a list - + - Save Cancel diff --git a/react/src/EditChallenge.tsx b/react/src/EditChallenge.tsx index 4316bae2..e5fbf1fb 100644 --- a/react/src/EditChallenge.tsx +++ b/react/src/EditChallenge.tsx @@ -1,98 +1,106 @@ -import React, {useEffect, 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 React, {useState} from 'react'; 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 {ChallengeDetailDto, ChallengeDto} from "./types/challenge.types"; - -const removeChallengesWithId = (challengeList: ChallengeDetailDto[], challengeId: number) => { - return challengeList.filter(details => details.id !== challengeId); -} - +import {ChallengeDetailDto, ChallengeDto} from "./types/challenge.types.ts"; +import {useMutation, useQueryClient, useSuspenseQuery} from "@tanstack/react-query"; +import {ensure, toChallengeDto, toLocalChallengeData} from "./utility/Mapper.ts"; +import Container from "react-bootstrap/Container"; +import {NavHeader} from "./App.tsx"; +import WarningButton from "./component/WarningButton.tsx"; +import ChallengeForm from "./ChallengeForm.tsx"; +import Button from 'react-bootstrap/esm/Button'; +import {httpDelete, httpGet, httpPut, UploadVars} from "./utility/apiClient.ts"; function EditChallenge() { - const navigate = useNavigate(); + let {publicId, challengeId} = useParams(); - const {publicId, challengeId} = useParams(); + const numericChallengeId = parseInt(ensure(challengeId), 10); - if (challengeId === undefined) { - throw new Error("Challenge ID is required."); - } + const navigate = useNavigate(); + + const challengeUrl = `/api/user/${publicId}/challenge`; + const challengeDetailUrl = `${challengeUrl}/${numericChallengeId}`; - const numericChallengeId = parseInt(challengeId); + const {data} = useSuspenseQuery({ + queryKey: [challengeDetailUrl], + queryFn: () => httpGet(challengeDetailUrl) + }); - const allChallengesEndpoint = `/api/user/${publicId}/challenge`; - const challengeEndpoint = `/api/user/${publicId}/challenge/${challengeId}`; + const [draftChallenge, setDraftChallenge] = useState(toLocalChallengeData(data)); - const [loaded, setLoaded] = useState(false); - const [editableChallenge, setEditableChallenge] = useState(emptyEditableChallenge()); const [dataValid, setDataValid] = useState(true); - const [savedChallenges, setSavedChallenges] = useState(emptyChallengeDataArray); - - const put = useApiPut(); - const callDelete = useApiDelete(); - - - 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(toLocalChallengeDataList) - .then(setSavedChallenges) - .catch(error => console.log(error)); - }, [allChallengesEndpoint]); + + 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 ] + }); + + queryClient.setQueryData([challengeDetailUrl], (oldData: ChallengeDetailDto) => { + return updatedChallenge; + }) + + navigate(-1); + }, + }); const onSave = () => { - put(challengeEndpoint, toChallengeDto(editableChallenge)).then(() => navigate(-1)); + updateChallenge.mutate({ + url: challengeDetailUrl, + body: toChallengeDto(draftChallenge) + }); } + + const deleteChallenge = useMutation({ + mutationFn: (url: string) => httpDelete(url), + onSuccess: (response) => { + + // remove the deleted challenge from the list of challenges + queryClient.setQueryData([challengeUrl], (oldData: ChallengeDetailDto[]) => { + return oldData.filter(challenge => challenge.id !== numericChallengeId); + }); + + // remove the delete challenge from the specific challenge detail cache + queryClient.invalidateQueries({queryKey: [response.url]}); + + navigate(-1); + }, + }); + const deleteById = () => { - callDelete(challengeEndpoint).then(() => navigate(-1)); + deleteChallenge.mutate(challengeDetailUrl); } return ( - - - - - This deletes the current sleep challenge and cannot be undone. Proceed? - - - - - {loaded - ? - : } - + <> + + + + + This deletes the current sleep challenge and cannot be undone. Proceed? + + + + + + + + + Save + navigate(-1)}>Cancel + - - Save - navigate(-1)}>Cancel - - - + + > ); } diff --git a/react/src/EditSleep.tsx b/react/src/EditSleep.tsx index ecb9e2e4..69af9445 100644 --- a/react/src/EditSleep.tsx +++ b/react/src/EditSleep.tsx @@ -19,10 +19,6 @@ function EditSleep() { const {publicId, sleepId} = useParams(); - if (publicId === undefined) { - throw new Error("Public ID is required in the url"); - } - const sleepEndpoint = `/api/user/${publicId}/sleep/${sleepId}`; const [loaded, setLoaded] = useState(false); diff --git a/react/src/SleepChart.tsx b/react/src/SleepChart.tsx index fdf48081..52b31db6 100644 --- a/react/src/SleepChart.tsx +++ b/react/src/SleepChart.tsx @@ -69,10 +69,6 @@ function SleepChart(props:{createdCount:number}) { const {publicId} = useParams(); - if (publicId === undefined) { - throw new Error("Public ID is required in the url"); - } - const {createdCount} = props; let [range, setRange] = useState(createRange(30)); diff --git a/react/src/SleepList.tsx b/react/src/SleepList.tsx index 2aafa518..5a3ec8e1 100644 --- a/react/src/SleepList.tsx +++ b/react/src/SleepList.tsx @@ -21,10 +21,6 @@ function SleepList(props:{createdCount: number}) { const {publicId} = useParams(); - if (publicId === undefined) { - throw new Error("Public ID is required in the url"); - } - const {createdCount} = props; const sleepUrl = `/api/user/${publicId}/sleep` diff --git a/react/src/Tools.tsx b/react/src/Tools.tsx index 8e4d003d..222d9251 100644 --- a/react/src/Tools.tsx +++ b/react/src/Tools.tsx @@ -91,7 +91,7 @@ function Tools() { mutationFn: (variables: UploadFileVariables) => uploadFileFunction(variables), onSuccess: (data: RecordCount) => { onUploadSuccess(data); - queryClient.setQueryData([sleepCountUrl], (oldData: RecordCount | undefined) => ({ + queryClient.setQueryData([sleepCountUrl], (oldData: RecordCount) => ({ numRecords: data.numRecords + (oldData?.numRecords ?? 0), })); }, diff --git a/react/src/index.tsx b/react/src/index.tsx index 1d8bc2d4..a2fd57b2 100644 --- a/react/src/index.tsx +++ b/react/src/index.tsx @@ -8,12 +8,6 @@ import {App} from './App'; const rootElement: HTMLElement = document.getElementById('root') !; const root = ReactDOM.createRoot(rootElement); -// if you use TypeScript, add non-null (!) assertion operator -// const root = createRoot(rootElement!); - root.render( ); - -// ReactDOM.render(, document.getElementById('root')); - diff --git a/react/src/types/challenge.types.ts b/react/src/types/challenge.types.ts index aae70470..1f6b44ba 100644 --- a/react/src/types/challenge.types.ts +++ b/react/src/types/challenge.types.ts @@ -23,10 +23,10 @@ interface ChallengeDetailDto { challenge: ChallengeDto } -interface ChallengeList { - current: T[]; - upcoming: T[]; - completed: T[]; +interface ChallengeList { + current: ChallengeData[]; + upcoming: ChallengeData[]; + completed: ChallengeData[]; } -export type { ChallengeData, ChallengeDto, ChallengeDetailDto, ChallengeList } \ No newline at end of file +export type { ChallengeData, ChallengeDto, ChallengeDetailDto, ChallengeList } diff --git a/react/src/utility/Constants.ts b/react/src/utility/Constants.ts index 2a5b1f8c..00b1d6d7 100644 --- a/react/src/utility/Constants.ts +++ b/react/src/utility/Constants.ts @@ -46,16 +46,14 @@ export const emptyEditableChallenge = ():ChallengeData => { return { id: 0, - name: "", + name: "Create your sleep hygiene challenge", description: "", start: startLocalDate, finish: finishLocalDate, }; } -export const emptyChallengeDataArray: ChallengeData[] = []; - -export const emptyChallengeList: ChallengeList = { +export const emptyChallengeList: ChallengeList = { current: [], upcoming: [], completed: [] diff --git a/react/src/utility/Mapper.ts b/react/src/utility/Mapper.ts index 8de66fae..281a7f5b 100644 --- a/react/src/utility/Mapper.ts +++ b/react/src/utility/Mapper.ts @@ -3,6 +3,13 @@ import {ChallengeDto, ChallengeDetailDto, ChallengeList, ChallengeData} from ".. import {ChronoUnit, convert, DateTimeFormatter, LocalDate, LocalDateTime, nativeJs} from "@js-joda/core" +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 as T; +} + const localDateTimeToString = (date: LocalDateTime): string => { return date.truncatedTo(ChronoUnit.SECONDS).format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); } @@ -27,7 +34,7 @@ const localDateTimeToJsDate = (date: LocalDateTime): Date => { return convert(date).toDate(); } -const toSelectableChallenges = (challengeList: ChallengeList, defaultChallenge: ChallengeData) => { +const toSelectableChallenges = (challengeList: ChallengeList, defaultChallenge: ChallengeData) => { let selectableChallenges = [...challengeList.current, ...challengeList.completed]; @@ -58,12 +65,7 @@ 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 toChallengeList = (challengeList: ChallengeDetailDto[]): ChallengeList => { const challengeData = challengeList.map(toLocalChallengeData); const now = LocalDate.now(); @@ -77,13 +79,6 @@ const toChallengeList = (challengeList: ChallengeDetailDto[]): ChallengeList { - return { - id: id, - challenge: dto - } -} - const toLocalChallengeData = (challengeDetails: ChallengeDetailDto): ChallengeData => { const challenge: ChallengeDto = challengeDetails.challenge; @@ -130,7 +125,7 @@ const toSleepDto = (sleep: SleepData): SleepDto => { } export { - toSelectableChallenges, toChallengeDto, toLocalChallengeData, toLocalChallengeDataList, toChallengeDetailDto, + ensure, toSelectableChallenges, toChallengeDto, toLocalChallengeData, toLocalSleepData, toSleepDto, calculateProgress, toChallengeList, jsDateToLocalDate, localDateToJsDate, jsDateToLocalDateTime, localDateTimeToJsDate, localDateToString } diff --git a/react/src/utility/apiClient.ts b/react/src/utility/apiClient.ts index 052f22f2..07acef39 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 UploadVars { url: string; - body: MessageDto; + body: T; } type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'; @@ -24,29 +23,27 @@ 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: 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(); - return data as T; + 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) => { 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 6c4a0009..6a9d1105 100644 --- a/server/src/main/java/com/seebie/server/controller/ChallengeController.java +++ b/server/src/main/java/com/seebie/server/controller/ChallengeController.java @@ -27,19 +27,19 @@ 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") @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") @GetMapping("/user/{publicId}/challenge/{challengeId}") - public ChallengeDto getChallenge(@PathVariable UUID publicId, @PathVariable Long challengeId) { + public ChallengeDetailDto getChallenge(@PathVariable UUID publicId, @PathVariable Long challengeId) { return challengeService.retrieve(publicId, challengeId); } @@ -60,4 +60,4 @@ public void delete(@PathVariable UUID publicId, @PathVariable Long challengeId) challengeService.remove(publicId, challengeId); } -} \ No newline at end of file +} diff --git a/server/src/main/java/com/seebie/server/controller/UserController.java b/server/src/main/java/com/seebie/server/controller/UserController.java index 32810f62..6b157284 100644 --- a/server/src/main/java/com/seebie/server/controller/UserController.java +++ b/server/src/main/java/com/seebie/server/controller/UserController.java @@ -52,7 +52,7 @@ public PagedModel getUsers(@PageableDefault(page = 0, size = 10, so } @PreAuthorize("hasRole('ROLE_ADMIN') || #publicId == authentication.principal.publicId") - @RequestMapping("/user/{publicId}/personalInfo") + @PutMapping("/user/{publicId}/personalInfo") public User updateUser(@Valid @RequestBody PersonalInfo userData, @PathVariable UUID publicId) { return userService.updateUser(publicId, userData); 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/repository/ChallengeRepository.java b/server/src/main/java/com/seebie/server/repository/ChallengeRepository.java index 7659d3bf..0b2addec 100644 --- a/server/src/main/java/com/seebie/server/repository/ChallengeRepository.java +++ b/server/src/main/java/com/seebie/server/repository/ChallengeRepository.java @@ -22,12 +22,12 @@ public interface ChallengeRepository extends JpaRepository { List findAllByUser(UUID publicId); @Query(""" - SELECT new com.seebie.server.dto.ChallengeDto(e.name, e.description, e.start, e.finish) + SELECT new com.seebie.server.dto.ChallengeDetailDto(e.id, e.name, e.description, e.start, e.finish) FROM Challenge e WHERE e.user.publicId=:publicId AND e.id=:challengeId """) - Optional findDtoBy(UUID publicId, Long challengeId); + Optional findDtoBy(UUID publicId, Long challengeId); @Query(""" SELECT e 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..bba2335c 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,20 +32,22 @@ 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) - public ChallengeDto retrieve(UUID publicId, Long challengeId) { + public ChallengeDetailDto retrieve(UUID publicId, Long challengeId) { return challengeRepo.findDtoBy(publicId, challengeId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Challenge not found")); } diff --git a/server/src/main/java/com/seebie/server/service/UserService.java b/server/src/main/java/com/seebie/server/service/UserService.java index e982028b..b81a8335 100644 --- a/server/src/main/java/com/seebie/server/service/UserService.java +++ b/server/src/main/java/com/seebie/server/service/UserService.java @@ -9,8 +9,6 @@ import com.seebie.server.repository.NotificationRepository; import com.seebie.server.repository.UserRepository; import jakarta.persistence.EntityNotFoundException; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PagedModel; import org.springframework.security.crypto.password.PasswordEncoder; @@ -18,13 +16,12 @@ import org.springframework.transaction.annotation.Transactional; import java.util.UUID; +import java.util.function.Supplier; @Service public class UserService { - private static final Logger LOG = LoggerFactory.getLogger(UserService.class); - private final UserMapper toUserRecord = new UserMapper(); private final UserRepository userRepo; @@ -39,20 +36,14 @@ public UserService(UserRepository repo, NotificationRepository notificationRepo, @Transactional public void updatePassword(UUID publicId, String newPassword) { - - userRepo.findByPublicId(publicId) - .ifPresentOrElse( - user -> user.setPassword(passwordEncoder.encode(newPassword)), - () -> { throw new EntityNotFoundException("No user found: " + publicId); } - ); + var user = userRepo.findByPublicId(publicId).orElseThrow(notFound(publicId)); + user.setPassword(passwordEncoder.encode(newPassword)); } @Transactional public com.seebie.server.dto.User updateUser(UUID publicId, PersonalInfo userData) { - var user = userRepo.findByPublicId(publicId) - .orElseThrow(() -> new EntityNotFoundException("No user found: " + publicId) ); - + var user = userRepo.findByPublicId(publicId).orElseThrow(notFound(publicId)); user.withUserData(userData.displayName(), userData.notificationsEnabled()); return toUserRecord.apply(user); @@ -80,10 +71,7 @@ public PagedModel getUserSummaries(Pageable page) { @Transactional(readOnly = true) public com.seebie.server.dto.User getUser(UUID publicId) { - - return userRepo.findByPublicId(publicId) - .map(toUserRecord) - .orElseThrow(() -> new EntityNotFoundException("no user found for " + publicId)); + return userRepo.findByPublicId(publicId).map(toUserRecord).orElseThrow(notFound(publicId)); } @Transactional(readOnly = true) @@ -93,4 +81,8 @@ public com.seebie.server.dto.User getUserByEmail(String email) { .map(toUserRecord) .orElseThrow(() -> new EntityNotFoundException("no user found for " + email)); } + + public static Supplier notFound(UUID publicId) { + return () -> new EntityNotFoundException("No user found: " + publicId); + } } diff --git a/server/src/test/java/com/seebie/server/service/ChallengeServiceIntegrationTest.java b/server/src/test/java/com/seebie/server/service/ChallengeServiceIntegrationTest.java index 089f75ec..5793e6cb 100644 --- a/server/src/test/java/com/seebie/server/service/ChallengeServiceIntegrationTest.java +++ b/server/src/test/java/com/seebie/server/service/ChallengeServiceIntegrationTest.java @@ -32,15 +32,15 @@ public void testRetrieveAndUpdate() { var savedChallenge = challengeService.saveNew(publicId, originalChallenge); // test retrieve - ChallengeDto found = challengeService.retrieve(publicId, savedChallenge.id()); + var found = challengeService.retrieve(publicId, savedChallenge.id()); - assertEquals(originalChallenge, found); + assertEquals(originalChallenge, found.challenge()); // test update var updated = new ChallengeDto("New title", "stuff", start, end); challengeService.update(publicId, savedChallenge.id(), updated); found = challengeService.retrieve(publicId, savedChallenge.id()); - assertEquals(updated, found); + assertEquals(updated, found.challenge()); } @Test