diff --git a/dfm-sideline-sidekick-app/ConditionSectionStyles.tsx b/dfm-sideline-sidekick-app/ConditionSectionStyles.tsx index 0b67ad4..e0af253 100644 --- a/dfm-sideline-sidekick-app/ConditionSectionStyles.tsx +++ b/dfm-sideline-sidekick-app/ConditionSectionStyles.tsx @@ -27,7 +27,7 @@ export default StyleSheet.create({ alignItems: "flex-start", justifyContent: "space-evenly", width: "100%", - marginTop: 45, + marginTop: 16, }, menuText: { fontFamily: "Roboto-Regular", @@ -73,7 +73,7 @@ export default StyleSheet.create({ }, information: { marginLeft: 16, - marginTop: 20, + marginTop: 0, marginRight: 16, }, overview: {}, diff --git a/dfm-sideline-sidekick-app/ConditionsSection.tsx b/dfm-sideline-sidekick-app/ConditionsSection.tsx index 790b46d..b534460 100644 --- a/dfm-sideline-sidekick-app/ConditionsSection.tsx +++ b/dfm-sideline-sidekick-app/ConditionsSection.tsx @@ -1,13 +1,48 @@ +// import { useRoute } from "@react-navigation/native"; +import { ParamListBase, RouteProp } from "@react-navigation/native"; +import { StackNavigationProp } from "@react-navigation/stack"; import * as Font from "expo-font"; import { useEffect, useState } from "react"; import { Image, Pressable, SafeAreaView, ScrollView, Text, View } from "react-native"; import styles from "./ConditionSectionStyles"; +import StringRenderer from "./components/StringRenderer"; +// import { getEmergency } from "./emergencies"; -export default function ConditionsSection() { +import type { Emergency } from "./emergencies"; + +export type RootStackParamList = { + // Define the parameters for your screens here + Conditions: { emergency: Emergency }; // Example parameter +} & ParamListBase; + +// Define the type for the route parameters +type ConditionsScreenRouteProp = RouteProp; + +// Define the type for the navigation object +type ConditionsScreenNavigationProp = StackNavigationProp; + +type Props = { + route: ConditionsScreenRouteProp; + navigation: ConditionsScreenNavigationProp; +}; + +type StringValue = string | string[] | { [key: string]: StringValue }; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export default function ConditionsSection({ route, navigation }: Props) { const [isOverviewPressed, setIsOverviewPressed] = useState(true); const [isTreatmentPressed, setIsTreatmentPressed] = useState(false); const [isFontsLoaded, setIsFontsLoaded] = useState(false); + const [overviewHeaders, setOverviewHeaders] = useState([]); + const [overviewValues, setOverviewValues] = useState([]); + const [treatmentHeaders, setTreatmentHeaders] = useState([]); + const [treatmentValues, setTreatmentValues] = useState([]); + const [contentHeaders, setContentHeaders] = useState([]); + const [contentValues, setContentValues] = useState([]); + + const { params } = route; // Destructure params from the route object + const [emergency, setEmergency] = useState(); useEffect(() => { async function loadFont() { @@ -27,20 +62,37 @@ export default function ConditionsSection() { void loadFont(); }, []); - type BulletListProps = { - items: string[]; - }; + useEffect(() => { + // if (params?.emergencyObjectId) { + // // Check if params and emergencyObjectId exist + // // eslint-disable-next-line @typescript-eslint/no-floating-promises + // getEmergency(params.emergencyObjectId).then((result) => { + // if (result.success) { + // setEmergency(result.data); + // } else { + // console.error("Error fetching emergency data:", result.error); + // } + // }); + // } + setEmergency(params.emergency); + }, [params]); // Include params in the dependency array + + useEffect(() => { + if (emergency?.overview && typeof emergency.overview === "object") { + setOverviewHeaders(Object.keys(emergency.overview)); + setOverviewValues(Object.values(emergency.overview) as StringValue[]); + } - const BulletList = ({ items }: BulletListProps) => ( - - {items.map((item: string, index: number) => ( - - {"\u2022"} - {item} - - ))} - - ); + if (emergency?.treatment && typeof emergency.treatment === "object") { + setTreatmentHeaders(Object.keys(emergency.treatment)); + setTreatmentValues(Object.values(emergency.treatment) as StringValue[]); + } + + if (emergency?.content && typeof emergency.content === "object") { + setContentHeaders(Object.keys(emergency.content)); + setContentValues(Object.values(emergency.content) as StringValue[]); + } + }, [emergency]); function onOverviewPress() { if (!isOverviewPressed) { @@ -67,7 +119,7 @@ export default function ConditionsSection() { Medical Emergency - Cervical Spine Injury + {emergency && {emergency.title}} @@ -91,69 +143,58 @@ export default function ConditionsSection() { - - Importance - - The cervical spine is not stabilized or protected by ribs or other surrounding - structures, so fractures are more common and can be unstable. This creates risk for - potential damage to the spinal cord resulting in quadriplegia and death and could be - made worse by moving patients without proper immobilization - - - - - Mechanism of Injury - - - - - Diagnosis - - - - - Physical Exam - - + {emergency?.overview && typeof emergency.overview === "string" && ( + + {emergency?.overview} + + )} + + {emergency?.overview && typeof emergency.overview === "object" && ( + + {overviewHeaders.map((header, index) => ( + + {header} + + + ))} + + )} - - Acute Management - - - - - Dispo - - - - - Considerations - - If any suspicion for injury, send to ED. However, less likely if the following - criteria are met: - - - + {emergency?.treatment && typeof emergency.treatment === "string" && ( + + {emergency?.treatment} + + )} + + {emergency?.treatment && typeof emergency.treatment === "object" && ( + + {treatmentHeaders.map((header, index) => ( + + {header} + + + ))} + + )} + + {emergency?.content && typeof emergency.content === "string" && ( + + {emergency?.content} + + )} + + {emergency?.content && typeof emergency.content === "object" && ( + + {contentHeaders.map((header, index) => ( + + {header} + + + ))} + + )} diff --git a/dfm-sideline-sidekick-app/HomeScreen.tsx b/dfm-sideline-sidekick-app/HomeScreen.tsx new file mode 100644 index 0000000..a2dc2c6 --- /dev/null +++ b/dfm-sideline-sidekick-app/HomeScreen.tsx @@ -0,0 +1,48 @@ +import { useNavigation } from "@react-navigation/native"; +import { useEffect, useState } from "react"; +import { Pressable, Text } from "react-native"; + +import { getEmergency } from "./emergencies"; + +export default function HomeScreen() { + const navigation = useNavigation(); + const [emergency, setEmergency] = useState(null); + + useEffect(() => { + async function fetchEmergency() { + try { + //It seems to work on Android and iPad + //Test Case: Cervical Spine Injury - demonstrate recursive rendering + const emergencyObjectId = "65b36d110c9c60394b37f7a1"; + //Separate Test Case here: To Be Deleted Emergency - demonstrates textual rendering + //const emergencyObjectId = "65b369a8e8fe96a404d4fd6b"; + //Test Case: New Emergency Placeholder Four - demonstrates blank rendering (only title in db) + //const emergencyObjectId = "65c2ef26b87b638ac61beb09"; + //Test Case: Cervical Strain - demonstrates simple placeholder headers + //const emergencyObjectId = "65b36f12640d62464e0dd129"; + const result = await getEmergency(emergencyObjectId); + if (result.success) { + setEmergency(result.data); + } else { + console.error("Error fetching emergency data:", result.error); + } + } catch (error) { + console.error("Error fetching emergency data:", error); + } + } + + void fetchEmergency(); + }, []); + + const handlePress = () => { + if (emergency !== null) { + navigation.navigate("Conditions", { emergency }); + } + }; + + return ( + + Navigate to ConditionsSection + + ); +} diff --git a/dfm-sideline-sidekick-app/components/StringRenderer.tsx b/dfm-sideline-sidekick-app/components/StringRenderer.tsx new file mode 100644 index 0000000..42859c9 --- /dev/null +++ b/dfm-sideline-sidekick-app/components/StringRenderer.tsx @@ -0,0 +1,43 @@ +import React from "react"; +import { Text, View } from "react-native"; + +import styles from "../ConditionSectionStyles"; + +type StringValue = string | string[] | { [key: string]: StringValue } | undefined; + +type Props = { + data: StringValue; +}; + +const BulletList: React.FC<{ items: string[] }> = ({ items }) => ( + + {items.map((item: string, index: number) => ( + + {"\u2022"} + {item} + + ))} + +); + +const StringRenderer: React.FC = ({ data }) => { + if (typeof data === "string") { + return {data}; + } else if (Array.isArray(data) && data.every((item) => typeof item === "string")) { + return ; + } else if (typeof data === "object") { + return ( + + {Object.keys(data).map((key, index) => ( + + {key} + + + ))} + + ); + } + return null; +}; + +export default StringRenderer; diff --git a/dfm-sideline-sidekick-app/emergencies.ts b/dfm-sideline-sidekick-app/emergencies.ts new file mode 100644 index 0000000..39943fd --- /dev/null +++ b/dfm-sideline-sidekick-app/emergencies.ts @@ -0,0 +1,106 @@ +import { get, handleAPIError, post, put } from "./requests"; + +import type { APIResult } from "./requests"; + +// export type Emergency = { +// _id: string; +// title: string; +// overview?: { +// Importance?: string; +// "Mechanism of Injury"?: string[]; +// Diagnosis?: string[]; +// "Physical Exam"?: string[]; +// }; +// treatment?: { +// "Acute Management"?: string[]; +// Dispo?: string[]; +// Considerations?: { +// Header?: string; +// Content?: string[]; +// }; +// }; +// content?: object; +// }; + +export type Emergency = { + _id: string; + title: string; + overview?: object; + treatment?: object; + content?: object; +}; + +/** + * The expected inputs when we want to create a new Task object. In the MVP, we only + * need to provide the title and optionally the description, but in the course of + * this tutorial you'll likely want to add more fields here. + */ +export type CreateEmergencyRequest = { + title: string; + overview?: object; + treatment?: object; + content?: object; +}; + +/** + * The expected inputs when we want to update an existing Task object. Similar to + * `CreateTaskRequest`. + */ +export type UpdateEmergencyRequest = { + _id: string; + title: string; + overview?: object; + treatment?: object; + content?: object; +}; + +/** + * The implementations of these API client functions are provided as part of the + * MVP. You can use them as a guide for writing the other client functions. + */ +export async function createEmergency( + emergency: CreateEmergencyRequest, +): Promise> { + try { + const response = await post("/api/emergencies", emergency); + const json = (await response.json()) as Emergency; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function getEmergency(id: string): Promise> { + try { + const response = await get(`/api/emergencies/${id}`); + const json = (await response.json()) as Emergency; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} + +export async function getAllEmergencies(): Promise> { + try { + const response = await get(`/api/emergencies/`); + const json = (await response.json()) as Emergency[]; + // const parsedJson = json.map((element) => (element)); + return { success: true, data: json }; + // your code here + } catch (error) { + return handleAPIError(error); + } +} + +export async function updateEmergency( + emergency: UpdateEmergencyRequest, +): Promise> { + try { + // your code here + const response = await put(`/api/emergencies/${emergency._id}`, emergency); + const json = (await response.json()) as Emergency; + return { success: true, data: json }; + } catch (error) { + return handleAPIError(error); + } +} diff --git a/dfm-sideline-sidekick-app/package-lock.json b/dfm-sideline-sidekick-app/package-lock.json index 6cacaba..6295aab 100644 --- a/dfm-sideline-sidekick-app/package-lock.json +++ b/dfm-sideline-sidekick-app/package-lock.json @@ -26,7 +26,8 @@ "react-native-screens": "~3.22.0", "react-native-svg": "13.9.0", "react-native-vector-icons": "^10.0.3", - "react-router-dom": "^6.21.3", + "react-router-dom": "^6.22.0", + "react-router-native": "^6.22.0", "typescript": "^5.1.3" }, "devDependencies": { @@ -6506,9 +6507,9 @@ } }, "node_modules/@remix-run/router": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.2.tgz", - "integrity": "sha512-ACXpdMM9hmKZww21yEqWwiLws/UPLhNKvimN8RrYSqPSvB3ov7sLvAcfvaxePeLvccTQKGdkDIhLYApZVDFuKg==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.0.tgz", + "integrity": "sha512-HOil5aFtme37dVQTB6M34G95kPM3MMuqSmIRVCC52eKV+Y/tGSqw9P3rWhlAx6A+mz+MoX+XxsGsNJbaI5qCgQ==", "engines": { "node": ">=14.0.0" } @@ -6979,6 +6980,11 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@ungap/url-search-params": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@ungap/url-search-params/-/url-search-params-0.2.2.tgz", + "integrity": "sha512-qQsguKXZVKdCixOHX9jqnX/K/1HekPDpGKyEcXHT+zR6EjGA7S4boSuelL4uuPv6YfhN0n8c4UxW+v/Z3gM2iw==" + }, "node_modules/@urql/core": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@urql/core/-/core-2.3.6.tgz", @@ -15465,11 +15471,11 @@ } }, "node_modules/react-router": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.3.tgz", - "integrity": "sha512-a0H638ZXULv1OdkmiK6s6itNhoy33ywxmUFT/xtSoVyf9VnC7n7+VT4LjVzdIHSaF5TIh9ylUgxMXksHTgGrKg==", + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.0.tgz", + "integrity": "sha512-q2yemJeg6gw/YixRlRnVx6IRJWZD6fonnfZhN1JIOhV2iJCPeRNSH3V1ISwHf+JWcESzLC3BOLD1T07tmO5dmg==", "dependencies": { - "@remix-run/router": "1.14.2" + "@remix-run/router": "1.15.0" }, "engines": { "node": ">=14.0.0" @@ -15479,12 +15485,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.21.3.tgz", - "integrity": "sha512-kNzubk7n4YHSrErzjLK72j0B5i969GsuCGazRl3G6j1zqZBLjuSlYBdVdkDOgzGdPIffUOc9nmgiadTEVoq91g==", + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.0.tgz", + "integrity": "sha512-z2w+M4tH5wlcLmH3BMMOMdrtrJ9T3oJJNsAlBJbwk+8Syxd5WFJ7J5dxMEW0/GEXD1BBis4uXRrNIz3mORr0ag==", "dependencies": { - "@remix-run/router": "1.14.2", - "react-router": "6.21.3" + "@remix-run/router": "1.15.0", + "react-router": "6.22.0" }, "engines": { "node": ">=14.0.0" @@ -15494,6 +15500,22 @@ "react-dom": ">=16.8" } }, + "node_modules/react-router-native": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/react-router-native/-/react-router-native-6.22.0.tgz", + "integrity": "sha512-liTx9D6VbiTKAYMmO5sZ3pvWEycxdhO4vec8zbhBwFZx9Ul4ZMsBkevtBo+gjZ9j57VYGOJ+BO0+Vqfu3Mh7vA==", + "dependencies": { + "@ungap/url-search-params": "^0.2.2", + "react-router": "6.22.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-native": ">=0.44" + } + }, "node_modules/react-shallow-renderer": { "version": "16.15.0", "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz", @@ -22280,9 +22302,9 @@ } }, "@remix-run/router": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.14.2.tgz", - "integrity": "sha512-ACXpdMM9hmKZww21yEqWwiLws/UPLhNKvimN8RrYSqPSvB3ov7sLvAcfvaxePeLvccTQKGdkDIhLYApZVDFuKg==" + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.0.tgz", + "integrity": "sha512-HOil5aFtme37dVQTB6M34G95kPM3MMuqSmIRVCC52eKV+Y/tGSqw9P3rWhlAx6A+mz+MoX+XxsGsNJbaI5qCgQ==" }, "@segment/loosely-validate-event": { "version": "2.0.0", @@ -22634,6 +22656,11 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "@ungap/url-search-params": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@ungap/url-search-params/-/url-search-params-0.2.2.tgz", + "integrity": "sha512-qQsguKXZVKdCixOHX9jqnX/K/1HekPDpGKyEcXHT+zR6EjGA7S4boSuelL4uuPv6YfhN0n8c4UxW+v/Z3gM2iw==" + }, "@urql/core": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/@urql/core/-/core-2.3.6.tgz", @@ -28904,20 +28931,29 @@ "integrity": "sha512-Hwln1VNuGl/6bVwnd0Xdn1e84gT/8T9aYNL+HAKDArLCS7LWjwr7StE30IEYbIkx0Vi3vs+coQxe+SQDbGbbpA==" }, "react-router": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.21.3.tgz", - "integrity": "sha512-a0H638ZXULv1OdkmiK6s6itNhoy33ywxmUFT/xtSoVyf9VnC7n7+VT4LjVzdIHSaF5TIh9ylUgxMXksHTgGrKg==", + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.0.tgz", + "integrity": "sha512-q2yemJeg6gw/YixRlRnVx6IRJWZD6fonnfZhN1JIOhV2iJCPeRNSH3V1ISwHf+JWcESzLC3BOLD1T07tmO5dmg==", "requires": { - "@remix-run/router": "1.14.2" + "@remix-run/router": "1.15.0" } }, "react-router-dom": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.21.3.tgz", - "integrity": "sha512-kNzubk7n4YHSrErzjLK72j0B5i969GsuCGazRl3G6j1zqZBLjuSlYBdVdkDOgzGdPIffUOc9nmgiadTEVoq91g==", + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.0.tgz", + "integrity": "sha512-z2w+M4tH5wlcLmH3BMMOMdrtrJ9T3oJJNsAlBJbwk+8Syxd5WFJ7J5dxMEW0/GEXD1BBis4uXRrNIz3mORr0ag==", + "requires": { + "@remix-run/router": "1.15.0", + "react-router": "6.22.0" + } + }, + "react-router-native": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/react-router-native/-/react-router-native-6.22.0.tgz", + "integrity": "sha512-liTx9D6VbiTKAYMmO5sZ3pvWEycxdhO4vec8zbhBwFZx9Ul4ZMsBkevtBo+gjZ9j57VYGOJ+BO0+Vqfu3Mh7vA==", "requires": { - "@remix-run/router": "1.14.2", - "react-router": "6.21.3" + "@ungap/url-search-params": "^0.2.2", + "react-router": "6.22.0" } }, "react-shallow-renderer": { diff --git a/dfm-sideline-sidekick-app/package.json b/dfm-sideline-sidekick-app/package.json index 1a84185..cef0463 100644 --- a/dfm-sideline-sidekick-app/package.json +++ b/dfm-sideline-sidekick-app/package.json @@ -33,7 +33,8 @@ "react-native-screens": "~3.22.0", "react-native-svg": "13.9.0", "react-native-vector-icons": "^10.0.3", - "react-router-dom": "^6.21.3", + "react-router-dom": "^6.22.0", + "react-router-native": "^6.22.0", "typescript": "^5.1.3" }, "devDependencies": { diff --git a/dfm-sideline-sidekick-app/requests.ts b/dfm-sideline-sidekick-app/requests.ts new file mode 100644 index 0000000..fb31db8 --- /dev/null +++ b/dfm-sideline-sidekick-app/requests.ts @@ -0,0 +1,180 @@ +/* eslint-disable @typescript-eslint/no-floating-promises */ +/** + * Based on the TSE Fulcrum API client implementation by justinyaodu: + * https://github.com/TritonSE/TSE-Fulcrum/blob/main/frontend/src/api.ts + */ + +/** + * A custom type defining which HTTP methods we will handle in this file + */ +type Method = "GET" | "POST" | "PUT"; + +/** + * The first part of the backend API URL, which we will automatically prepend to + * every request. This means in the rest of our code, we can write "/api/foo" + * instead of "http://localhost:3001/api/foo". + */ +//const API_BASE_URL = process.env.REACT_APP_API_BASE_URL || "http://localhost:3001"; +// NOTE TO REVIEWERS: INSERT IP ADDRESS HERE +// YOU MUST BE ON THE SAME NETWORK AS TEST DEVICE AND HAVE BACKEND RUNNING (WITH .env SETUP and "Database Connected" message) +const API_BASE_URL = "http://100.112.104.53:3001"; + +/** + * A wrapper around the built-in `fetch()` function that abstracts away some of + * the low-level details so we can focus on the important parts of each request. + * See https://developer.mozilla.org/en-US/docs/Web/API/fetch for information + * about the Fetch API. + * + * @param method The HTTP method to use + * @param url The URL to request + * @param body The body of the request, or undefined if there is none + * @param headers The headers of the request + * @returns The Response object returned by `fetch() + */ +async function fetchRequest( + method: Method, + url: string, + body: unknown, + headers: Record, +): Promise { + const hasBody = body !== undefined; + + const newHeaders = { ...headers }; + if (hasBody) { + newHeaders["Content-Type"] = "application/json"; + } + + const response = await fetch(url, { + method, + headers: newHeaders, + body: hasBody ? JSON.stringify(body) : undefined, + }); + + return response; +} + +/** + * Throws an error if the given response's status code indicates an error + * occurred, else does nothing. + * + * @param response A response returned by `fetch()` or `fetchRequest()` + * @throws An error if the response was not successful (200-299) or a redirect + * (300-399) + */ +async function assertOk(response: Response): Promise { + if (response.ok) { + return; + } + + let message = `${response.status} ${response.statusText}`; + + try { + const text = await response.text(); + if (text) { + message += ": " + text; + } + } catch (e) { + // skip errors + } + + throw new Error(message); +} + +/** + * Sends a GET request to the provided API URL. + * + * @param url The URL to request + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function get(url: string, headers: Record = {}): Promise { + // GET requests do not have a body + const response = await fetchRequest("GET", API_BASE_URL + url, undefined, headers); + assertOk(response); + return response; +} + +/** + * Sends a POST request to the provided API URL. + * + * @param url The URL to request + * @param body The body of the request, or undefined if there is none + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function post( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + const response = await fetchRequest("POST", API_BASE_URL + url, body, headers); + assertOk(response); + return response; +} + +/** + * Sends a PUT request to the provided API URL. + * + * @param url The URL to request + * @param body The body of the request, or undefined if there is none + * @param headers The headers of the request (optional) + * @returns The Response object returned by `fetch()` + */ +export async function put( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + const response = await fetchRequest("PUT", API_BASE_URL + url, body, headers); + assertOk(response); + return response; +} + +export type APIData = { success: true; data: T }; +export type APIError = { success: false; error: string }; +/** + * Utility type for the result of an API request. API client functions should + * always return an object of this type (without throwing an exception if + * something goes wrong). This allows users of the functions to perform easier + * error checking without excessive try-catch statements, making use of + * TypeScript's type narrowing feature. Specifically, by checking whether the + * `success` field is true or false, you'll know whether you can access the + * `data` field with the actual API response or the `error` field with an error + * message. + * + * For example, assume we have some API function with the type definition + * `doSomeRequest: (parameters: SomeParameters) => Promise>`. + * Then we could use it in a frontend component as follows: + * ``` + * doSomeRequest(parameters).then((result: APIResult) => { + * if (result.success) { + * console.log(result.data); // do something with the data, which is of type SomeData + * } else { + * console.error(result.error); // do something to inform the user of the error + * } + * }) + * ``` + * + * See `createTask` in `src/api/tasks` and its use in `src/components/TaskForm` + * for a more concrete example, and see + * https://www.typescriptlang.org/docs/handbook/2/narrowing.html for more info + * about type narrowing. + */ +export type APIResult = APIData | APIError; + +/** + * Helper function for API client functions to handle errors consistently. + * Recommended usage is in a `catch` block--see `createTask` in `src/api/tasks` + * for an example. + * + * @param error An error thrown by a lower-level API function + * @returns An `APIError` object with a message from the given error + */ +export function handleAPIError(error: unknown): APIError { + if (error instanceof Error) { + return { success: false, error: error.message }; + } else if (typeof error === "string") { + return { success: false, error }; + } + return { success: false, error: `Unknown error: ${String(error)}` }; +}