From 7c9ce8723669e43176160cc7de1a688543bf5e52 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Fri, 7 Mar 2025 21:23:35 -0600 Subject: [PATCH 01/21] Change OrganizationAPI to proper camel-case. --- src/lib/api/organizations.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/lib/api/organizations.ts b/src/lib/api/organizations.ts index 8f95a0f3..c0f346f8 100644 --- a/src/lib/api/organizations.ts +++ b/src/lib/api/organizations.ts @@ -11,7 +11,7 @@ import { EntityApi } from "./entity-api"; * This object provides a collection of functions for interacting with the organization endpoints * on the backend service. It handles the HTTP requests and response parsing for all CRUD operations. */ -export const OrganizationAPI = { +export const OrganizationApi = { /* * Fetches a list of organizations associated with a specific user. * @@ -49,6 +49,10 @@ export const OrganizationAPI = { organization ), + createNested: async (id: Id, entity: Organization): Promise => { + throw new Error("Create nested operation not implemented"); + }, + /** * Updates an existing organization. * @@ -92,7 +96,7 @@ export const useOrganizationList = (userId: Id) => { const { entities, isLoading, isError, refresh } = EntityApi.useEntityList( `${siteConfig.env.backendServiceURL}/organizations`, - () => OrganizationAPI.list(userId), + () => OrganizationApi.list(userId), userId ); @@ -121,7 +125,7 @@ export const useOrganization = (id: Id) => { const url = id ? `${siteConfig.env.backendServiceURL}/organizations/${id}` : null; - const fetcher = () => OrganizationAPI.get(id); + const fetcher = () => OrganizationApi.get(id); const { entity, isLoading, isError, refresh } = EntityApi.useEntity(url, fetcher, defaultOrganization()); @@ -154,9 +158,10 @@ export const useOrganizationMutation = () => { return EntityApi.useEntityMutation( `${siteConfig.env.backendServiceURL}/organizations`, { - create: OrganizationAPI.create, - update: OrganizationAPI.update, - delete: OrganizationAPI.delete, + create: OrganizationApi.create, + createNested: OrganizationApi.createNested, + update: OrganizationApi.update, + delete: OrganizationApi.delete, } ); }; From f9475ef3067af64601014a6027641c1a42a4c217 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Fri, 7 Mar 2025 21:25:12 -0600 Subject: [PATCH 02/21] Refactor the Coaching Relationships API to use the new function/hook pattern. Also add a new entity-api method, createNested which creates a new entity nested under the ID of another entity. --- .../ui/coaching-relationship-selector.tsx | 4 +- src/lib/api/coaching-relationships.ts | 307 +++++++++--------- src/lib/api/entity-api.ts | 3 + 3 files changed, 164 insertions(+), 150 deletions(-) diff --git a/src/components/ui/coaching-relationship-selector.tsx b/src/components/ui/coaching-relationship-selector.tsx index 2456fa45..cb75ddab 100644 --- a/src/components/ui/coaching-relationship-selector.tsx +++ b/src/components/ui/coaching-relationship-selector.tsx @@ -9,7 +9,7 @@ import { SelectValue, } from "@/components/ui/select"; import { Id } from "@/types/general"; -import { useCoachingRelationships } from "@/lib/api/coaching-relationships"; +import { useCoachingRelationshipList } from "@/lib/api/coaching-relationships"; import { useEffect } from "react"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; import { useCoachingRelationshipStateStore } from "@/lib/providers/coaching-relationship-state-store-provider"; @@ -30,7 +30,7 @@ function CoachingRelationshipsSelectItems({ organizationId: Id; }) { const { relationships, isLoading, isError } = - useCoachingRelationships(organizationId); + useCoachingRelationshipList(organizationId); const { setCurrentCoachingRelationships } = useCoachingRelationshipStateStore( (state) => state ); diff --git a/src/lib/api/coaching-relationships.ts b/src/lib/api/coaching-relationships.ts index 8269a778..a4fdee9a 100644 --- a/src/lib/api/coaching-relationships.ts +++ b/src/lib/api/coaching-relationships.ts @@ -1,167 +1,178 @@ // Interacts with the coaching_relationship endpoints import { siteConfig } from "@/site.config"; +import { Id } from "@/types/general"; import { CoachingRelationshipWithUserNames, - coachingRelationshipsWithUserNamesToString, defaultCoachingRelationshipWithUserNames, - defaultCoachingRelationshipsWithUserNames, - isCoachingRelationshipWithUserNames, - isCoachingRelationshipWithUserNamesArray, - parseCoachingRelationshipWithUserNames, } from "@/types/coaching_relationship_with_user_names"; -import { Id } from "@/types/general"; -import axios, { AxiosError, AxiosResponse } from "axios"; -import useSWR from "swr"; - -interface ApiResponse { - status_code: number; - data: CoachingRelationshipWithUserNames[]; -} - -const fetcher = async ( - url: string -): Promise => - axios - .get(url, { - withCredentials: true, - timeout: 5000, - headers: { - "X-Version": siteConfig.env.backendApiVersion, - }, - }) - .then((res) => res.data.data); +import { EntityApi } from "./entity-api"; + +/** + * API client for organization-related operations. + * + * This object provides a collection of functions for interacting with the organization endpoints + * on the backend service. It handles the HTTP requests and response parsing for all CRUD operations. + */ +export const CoachingRelationshipApi = { + /* + * Fetches a list of coaching relationships associated with a specific organization. + * + * @param organizationId The ID of the organization under which to retrieve all coaching relationships + * @returns Promise resolving to an array of CoachingRelationshipsWithUserNames objects + */ + list: async ( + organizationId: Id + ): Promise => + EntityApi.listFn( + `${siteConfig.env.backendServiceURL}/organizations/${organizationId}/coaching_relationships`, + { + params: { organization_id: organizationId }, + } + ), + + /** + * Fetches a single coaching relationship by its ID. + * + * @param organizationId The ID of the organization to retrieve a relationship under + * @param relationshipId The ID of the coaching relationship to retrieve + * @returns Promise resolving to the CoachingRelationshipWithUserNames object + */ + get: async ( + organizationId: Id, + relationshipId: Id + ): Promise => + EntityApi.getFn( + `${siteConfig.env.backendServiceURL}/organizations/${organizationId}/coaching_relationships/${relationshipId}` + ), + + /** + * Creates a new coaching relationship. + * + * @param organizationId The organization ID under which to create the new coaching relationship + * @param relationship The coaching relationship data to create + * @returns Promise resolving to the created CoachingRelationshipWithUserNames object + */ + create: async ( + _relationship: CoachingRelationshipWithUserNames + ): Promise => { + throw new Error("Create operation not implemented"); + }, + + createNested: async ( + organizationId: Id, + entity: CoachingRelationshipWithUserNames + ): Promise => { + return EntityApi.createFn< + CoachingRelationshipWithUserNames, + CoachingRelationshipWithUserNames + >( + `${siteConfig.env.backendServiceURL}/organizations/${organizationId}/coaching_relationships`, + entity + ); + }, -/// A hook to retrieve all CoachingRelationships associated with organizationId -export function useCoachingRelationships(organizationId: Id) { - console.debug(`organizationId: ${organizationId}`); + update: async (_id: Id, entity: CoachingRelationshipWithUserNames) => { + throw new Error("Update operation not implemented"); + }, - const { data, error, isLoading } = useSWR< - CoachingRelationshipWithUserNames[] - >( - organizationId ? - `${siteConfig.env.backendServiceURL}/organizations/${organizationId}/coaching_relationships` : null, - fetcher - ); + delete: async (_id: Id) => { + throw new Error("Delete operation not implemented"); + }, +}; - console.debug(`data: ${JSON.stringify(data)}`); +/** + * A custom React hook that fetches a list of coaching relationships for a specific organization. + * + * This hook uses SWR to efficiently fetch, cache, and revalidate coaching relationship data. + * It automatically refreshes data when the component mounts. + * + * @param organizationId The ID of the organization whose coaching relationships should be fetched + * @returns An object containing: + * + * * relationships: Array of CoachingRelationshipWithUserNames objects (empty array if data is not yet loaded) + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useCoachingRelationshipList = (organizationId: Id) => { + const { entities, isLoading, isError, refresh } = + EntityApi.useEntityList( + `${siteConfig.env.backendServiceURL}/organizations/${organizationId}/coaching_relationships`, + () => CoachingRelationshipApi.list(organizationId), + organizationId + ); return { - relationships: Array.isArray(data) ? data : [], + relationships: entities, isLoading, - isError: error, + isError, + refresh, }; -} - -export const fetchCoachingRelationshipWithUserNames = async ( - organization_id: Id, - relationship_id: Id -): Promise => { - const axios = require("axios"); - - var relationship: CoachingRelationshipWithUserNames = - defaultCoachingRelationshipWithUserNames(); - var err: string = ""; - - const data = await axios - .get( - `${siteConfig.env.backendServiceURL}/organizations/${organization_id}/coaching_relationships/${relationship_id}`, - { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - }, - } - ) - .then(function (response: AxiosResponse) { - // handle success - const relationshipData = response.data.data; - if (isCoachingRelationshipWithUserNames(relationshipData)) { - relationship = parseCoachingRelationshipWithUserNames(relationshipData); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = - "Retrieval of CoachingRelationshipWithUserNames failed: unauthorized."; - } else if (error.response?.status == 500) { - err = - "Retrieval of CoachingRelationshipWithUserNames failed, system error: " + - error.response.data; - } else { - err = - `Retrieval of CoachingRelationshipWithUserNames(` + - relationship_id + - `) failed: ` + - error.response?.data; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return relationship; }; -export const fetchCoachingRelationshipsWithUserNames = async ( - organizationId: Id -): Promise<[CoachingRelationshipWithUserNames[], string]> => { - const axios = require("axios"); +/** + * A custom React hook that fetches a single coaching relationship by its ID. + * This hook uses SWR to efficiently fetch and cache organization data. + * It does not automatically revalidate the data on window focus, reconnect, or when data becomes stale. + * + * @param organizationId The ID of the organization under which to fetch the coaching relationship. If null or undefined, no fetch will occur. + * @param relationshipId The ID of the coaching relationship to fetch. If null or undefined, no fetch will occur. + * @returns An object containing: + * + * * relationship: The fetched CoachingRelationshipWithUserNames object, or a default relationship if not yet loaded + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useCoachingRelationship = ( + organizationId: Id, + relationshipId: Id +) => { + const url = + organizationId && relationshipId + ? `${siteConfig.env.backendServiceURL}/organizations/${organizationId}/coaching_relationships/${relationshipId}` + : null; + const fetcher = () => + CoachingRelationshipApi.get(organizationId, relationshipId); + + const { entity, isLoading, isError, refresh } = + EntityApi.useEntity( + url, + fetcher, + defaultCoachingRelationshipWithUserNames() + ); - var relationships: CoachingRelationshipWithUserNames[] = - defaultCoachingRelationshipsWithUserNames(); - var err: string = ""; - - const data = await axios - .get( - `${siteConfig.env.backendServiceURL}/organizations/${organizationId}/coaching_relationships`, - { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - }, - } - ) - .then(function (response: AxiosResponse) { - // handle success - console.debug(response); - if (isCoachingRelationshipWithUserNamesArray(response.data.data)) { - relationships = response.data.data; - console.debug( - `CoachingRelationshipsWithUserNames: ` + - coachingRelationshipsWithUserNamesToString(relationships) + - `.` - ); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - console.error( - "Retrieval of CoachingRelationshipsWithUserNames failed: unauthorized." - ); - err = - "Retrieval of CoachingRelationshipsWithUserNames failed: unauthorized."; - } else { - console.log(error); - console.error( - `Retrieval of CoachingRelationshipsWithUserNames by organization Id (` + - organizationId + - `) failed.` - ); - err = - `Retrieval of CoachingRealtionshipsWithUserNames by organization Id (` + - organizationId + - `) failed.`; - } - }); + return { + relationship: entity, + isLoading, + isError, + refresh, + }; +}; - return [relationships, err]; +/** + * A custom React hook that provides mutation operations for coaching relationships with loading and error state management. + * This hook simplifies creating coaching relationships while handling loading states, + * error management, and cache invalidation automatically. + * + * @returns An object containing: + * create: Function to create a new coaching relationship + * isLoading: Boolean indicating if any operation is in progress + * error: Error object if the last operation failed, null otherwise + */ +/** + * Hook for coaching relationship mutations. + * Provides methods to create, update, and delete coaching relationships. + */ +export const useCoachingRelationshipMutation = (organizationId: Id) => { + return EntityApi.useEntityMutation( + `${siteConfig.env.backendServiceURL}/organizations/${organizationId}/coaching_relationships`, + { + create: CoachingRelationshipApi.create, + createNested: CoachingRelationshipApi.createNested, + update: CoachingRelationshipApi.update, + delete: CoachingRelationshipApi.delete, + } + ); }; diff --git a/src/lib/api/entity-api.ts b/src/lib/api/entity-api.ts index 83c6d916..8e4c60f0 100644 --- a/src/lib/api/entity-api.ts +++ b/src/lib/api/entity-api.ts @@ -205,6 +205,7 @@ export namespace EntityApi { baseUrl: string, api: { create: (entity: T) => Promise; + createNested: (id: Id, entity: T) => Promise; update: (id: Id, entity: T) => Promise; delete: (id: Id) => Promise; } @@ -251,6 +252,8 @@ export namespace EntityApi { * @returns Promise resolving to the created entity */ create: (entity: T) => executeWithState(() => api.create(entity)), + createNested: (id: Id, entity: T) => + executeWithState(() => api.createNested(id, entity)), /** * Updates an existing entity. * From f6aa87ed747ee596e97826c8acd2039350728eb0 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Fri, 7 Mar 2025 21:27:29 -0600 Subject: [PATCH 03/21] Update function documentation. --- src/lib/api/coaching-relationships.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/lib/api/coaching-relationships.ts b/src/lib/api/coaching-relationships.ts index a4fdee9a..4858dc3e 100644 --- a/src/lib/api/coaching-relationships.ts +++ b/src/lib/api/coaching-relationships.ts @@ -47,11 +47,7 @@ export const CoachingRelationshipApi = { ), /** - * Creates a new coaching relationship. - * - * @param organizationId The organization ID under which to create the new coaching relationship - * @param relationship The coaching relationship data to create - * @returns Promise resolving to the created CoachingRelationshipWithUserNames object + * Unimplemented */ create: async ( _relationship: CoachingRelationshipWithUserNames @@ -59,6 +55,13 @@ export const CoachingRelationshipApi = { throw new Error("Create operation not implemented"); }, + /** + * Creates a new coaching relationship. + * + * @param organizationId The organization ID under which to create the new coaching relationship + * @param relationship The coaching relationship data to create + * @returns Promise resolving to the created CoachingRelationshipWithUserNames object + */ createNested: async ( organizationId: Id, entity: CoachingRelationshipWithUserNames @@ -72,10 +75,16 @@ export const CoachingRelationshipApi = { ); }, + /** + * Unimplemented + */ update: async (_id: Id, entity: CoachingRelationshipWithUserNames) => { throw new Error("Update operation not implemented"); }, + /** + * Unimplemented + */ delete: async (_id: Id) => { throw new Error("Delete operation not implemented"); }, From 1b4d63ab6ef18f5065b86e579dbe1dcea50da143 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Wed, 12 Mar 2025 13:09:35 -0500 Subject: [PATCH 04/21] Refactor the OverarchingGoal API to use the new function/hook pattern. --- .../ui/coaching-session-selector.tsx | 8 +- src/components/ui/coaching-session.tsx | 4 +- .../overarching-goal-container.tsx | 114 ++--- src/lib/api/overarching-goals.ts | 478 +++++++----------- 4 files changed, 226 insertions(+), 378 deletions(-) diff --git a/src/components/ui/coaching-session-selector.tsx b/src/components/ui/coaching-session-selector.tsx index dda2338d..0f4e690c 100644 --- a/src/components/ui/coaching-session-selector.tsx +++ b/src/components/ui/coaching-session-selector.tsx @@ -15,7 +15,7 @@ import { useCoachingSessions } from "@/lib/api/coaching-sessions"; import { useEffect, useState } from "react"; import { DateTime } from "ts-luxon"; import { useCoachingSessionStateStore } from "@/lib/providers/coaching-session-state-store-provider"; -import { fetchOverarchingGoalsByCoachingSessionId } from "@/lib/api/overarching-goals"; +import { OverarchingGoalApi } from "@/lib/api/overarching-goals"; import { OverarchingGoal } from "@/types/overarching-goal"; import { CoachingSession } from "@/types/coaching-session"; @@ -56,7 +56,7 @@ function CoachingSessionsSelectItems({ try { const sessionIds = coachingSessions?.map((session) => session.id) || []; const goalsPromises = sessionIds.map((id) => - fetchOverarchingGoalsByCoachingSessionId(id) + OverarchingGoalApi.list(id) ); const fetchedGoals = await Promise.all(goalsPromises); setGoals(fetchedGoals); @@ -163,9 +163,7 @@ export default function CoachingSessionSelector({ setIsLoadingGoal(true); try { - const goals = await fetchOverarchingGoalsByCoachingSessionId( - currentCoachingSessionId - ); + const goals = await OverarchingGoalApi.list(currentCoachingSessionId); setCurrentGoal(goals[0]); } catch (error) { console.error("Error fetching goal:", error); diff --git a/src/components/ui/coaching-session.tsx b/src/components/ui/coaching-session.tsx index add3f5ce..4c3ed7e3 100644 --- a/src/components/ui/coaching-session.tsx +++ b/src/components/ui/coaching-session.tsx @@ -6,7 +6,7 @@ import { Card, CardHeader } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import Link from "next/link"; import { useCoachingSessionStateStore } from "@/lib/providers/coaching-session-state-store-provider"; -import { useOverarchingGoalByCoachingSessionId } from "@/lib/api/overarching-goals"; +import { useOverarchingGoalBySession } from "@/lib/api/overarching-goals"; import { Id } from "@/types/general"; interface CoachingSessionProps { @@ -59,7 +59,7 @@ const OverarchingGoal: React.FC = ({ overarchingGoal, isLoading: isLoadingOverarchingGoal, isError: isErrorOverarchingGoal, - } = useOverarchingGoalByCoachingSessionId(coachingSessionId); + } = useOverarchingGoalBySession(coachingSessionId); let titleText: string; diff --git a/src/components/ui/coaching-sessions/overarching-goal-container.tsx b/src/components/ui/coaching-sessions/overarching-goal-container.tsx index bfd7b196..4f2b98fd 100644 --- a/src/components/ui/coaching-sessions/overarching-goal-container.tsx +++ b/src/components/ui/coaching-sessions/overarching-goal-container.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { ActionsList } from "@/components/ui/coaching-sessions/actions-list"; import { ItemStatus, Id } from "@/types/general"; import { Action } from "@/types/action"; @@ -18,12 +18,10 @@ import { siteConfig } from "@/site.config"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { OverarchingGoalComponent } from "./overarching-goal"; import { - createOverarchingGoal, - fetchOverarchingGoalsByCoachingSessionId, - updateOverarchingGoal, + useOverarchingGoalBySession, + useOverarchingGoalMutation, } from "@/lib/api/overarching-goals"; import { - defaultOverarchingGoal, OverarchingGoal, overarchingGoalToString, } from "@/types/overarching-goal"; @@ -33,11 +31,12 @@ const OverarchingGoalContainer: React.FC<{ userId: Id; }> = ({ userId }) => { const [isOpen, setIsOpen] = useState(false); - const [goal, setGoal] = useState(defaultOverarchingGoal()); - const [goalId, setGoalId] = useState(""); const { currentCoachingSessionId } = useCoachingSessionStateStore( (state) => state ); + const { overarchingGoal, isLoading, isError, refresh } = + useOverarchingGoalBySession(currentCoachingSessionId); + const { create, update } = useOverarchingGoalMutation(); const handleAgreementAdded = (body: string): Promise => { // Calls the backend endpoint that creates and stores a full Agreement entity @@ -116,76 +115,53 @@ const OverarchingGoalContainer: React.FC<{ }); }; - useEffect(() => { - async function fetchOverarchingGoal() { - if (!currentCoachingSessionId) return; - - await fetchOverarchingGoalsByCoachingSessionId(currentCoachingSessionId) - .then((goals) => { - const goal = goals[0]; - if (goals.length > 0) { - console.trace("Overarching Goal: " + overarchingGoalToString(goal)); - setGoalId(goal.id); - setGoal(goal); - } else { - console.trace( - "No Overarching Goals associated with this coachingSessionId" - ); - } - }) - .catch((err) => { - console.error( - "Failed to fetch Overarching Goal for current coaching session: " + - err - ); - }); - } - fetchOverarchingGoal(); - }, [currentCoachingSessionId]); - const handleGoalChange = async (newGoal: OverarchingGoal) => { - console.trace("handleGoalChange (goal to set/update): " + newGoal.title); + // console.trace( + // "handleGoalChange (goal title to set/update): " + newGoal.title + // ); + // console.trace( + // "handleGoalChange (goal to set/update): " + + // overarchingGoalToString(newGoal) + // ); + // console.trace( + // "handleGoalChange (overarchingGoal.id , currentCoachingSessionId set/update): " + + // overarchingGoal.id + + // ", " + + // currentCoachingSessionId + // ); - if (goalId && currentCoachingSessionId) { - console.debug("Update existing Overarching Goal with id: " + goalId); - updateOverarchingGoal( - goalId, - currentCoachingSessionId, - newGoal.title, - newGoal.body, - newGoal.status - ) - .then((responseGoal) => { + try { + if (currentCoachingSessionId) { + if (overarchingGoal.id) { + // console.debug( + // "Update existing Overarching Goal with id: " + overarchingGoal.id + // ); + const responseGoal = await update(overarchingGoal.id, newGoal); console.trace( "Updated Overarching Goal: " + overarchingGoalToString(responseGoal) ); - setGoal(responseGoal); - }) - .catch((err) => { - console.error("Failed to update Overarching Goal: " + err); - }); - } else if (!goalId && currentCoachingSessionId) { - createOverarchingGoal( - currentCoachingSessionId, - newGoal.title, - newGoal.body, - newGoal.status - ) - .then((responseGoal) => { + } else if (!overarchingGoal.id) { + newGoal.coaching_session_id = currentCoachingSessionId; + // console.trace( + // "Creating new Overarching Goal: " + overarchingGoalToString(newGoal) + // ); + const responseGoal = await create(newGoal); console.trace( "Newly created Overarching Goal: " + overarchingGoalToString(responseGoal) ); - setGoal(responseGoal); - setGoalId(responseGoal.id); - }) - .catch((err) => { - console.error("Failed to create new Overarching Goal: " + err); - }); - } else { - console.error( - "Could not update or create a Overarching Goal since coachingSessionId or userId are not set." - ); + + // Manually trigger a local refresh of the cached OverarchingGoal data such that + // any other local code using the KeyedMutator will also update with this new data. + refresh(); + } + } else { + console.error( + "Could not update or create a Overarching Goal since coachingSessionId or userId are not set." + ); + } + } catch (err) { + console.error("Failed to update or create Overarching Goal: " + err); } }; @@ -198,7 +174,7 @@ const OverarchingGoalContainer: React.FC<{ className="w-full space-y-2" > setIsOpen(open)} onGoalChange={(goal: OverarchingGoal) => handleGoalChange(goal)} > diff --git a/src/lib/api/overarching-goals.ts b/src/lib/api/overarching-goals.ts index 737f69dc..3acd09e7 100644 --- a/src/lib/api/overarching-goals.ts +++ b/src/lib/api/overarching-goals.ts @@ -1,65 +1,167 @@ // Interacts with the overarching_goal endpoints +import { siteConfig } from "@/site.config"; +import { Id } from "@/types/general"; import { OverarchingGoal, defaultOverarchingGoal, - isOverarchingGoal, - isOverarchingGoalArray, - parseOverarchingGoal, } from "@/types/overarching-goal"; -import { ItemStatus, Id } from "@/types/general"; -import axios, { AxiosError, AxiosResponse } from "axios"; -import { siteConfig } from "@/site.config"; -import useSWR, { useSWRConfig } from "swr"; - -interface ApiResponseOverarchingGoals { - status_code: number; - data: OverarchingGoal[]; -} +import { EntityApi } from "./entity-api"; + +const OVERARCHING_GOALS_BASEURL: string = `${siteConfig.env.backendServiceURL}/overarching_goals`; + +/** + * API client for overarching-goal-related operations. + * + * This object provides a collection of functions for interacting with the overarching-goal endpoints + * on the backend service. It handles the HTTP requests and response parsing for all CRUD operations. + */ +export const OverarchingGoalApi = { + /* + * Fetches a list of overarching-goals associated with a specific user. + * + * @param userId The ID of the user whose overarching-goal should be retrieved + * @returns Promise resolving to an array of OverarchingGoal objects + */ + list: async (coachingSessionId: Id): Promise => + EntityApi.listFn(OVERARCHING_GOALS_BASEURL, { + params: { coaching_session_id: coachingSessionId }, + }), + + /** + * Fetches a single overarching-goal by its ID. + * + * @param id The ID of the overarching-goal to retrieve + * @returns Promise resolving to the OverarchingGoal object + */ + get: async (id: Id): Promise => + EntityApi.getFn(`${OVERARCHING_GOALS_BASEURL}/${id}`), + + /** + * Creates a new overarching-goal. + * + * @param overarchingGoal The overarching-goal data to create + * @returns Promise resolving to the created OverarchingGoal object + */ + create: async (overarchingGoal: OverarchingGoal): Promise => + EntityApi.createFn( + OVERARCHING_GOALS_BASEURL, + overarchingGoal + ), + + createNested: async ( + _id: Id, + _entity: OverarchingGoal + ): Promise => { + throw new Error("Create nested operation not implemented"); + }, + + /** + * Updates an existing overarching-goal. + * + * @param id The ID of the overarching-goal to update + * @param overarchingGoal The updated overarching-goal data + * @returns Promise resolving to the updated OverarchingGoal object + */ + update: async ( + id: Id, + overarchingGoal: OverarchingGoal + ): Promise => + EntityApi.updateFn( + `${OVERARCHING_GOALS_BASEURL}/${id}`, + overarchingGoal + ), + + /** + * Deletes an overarching-goal. + * + * @param id The ID of the overarching-goal to delete + * @returns Promise resolving to the deleted OverarchingGoal object + */ + delete: async (id: Id): Promise => + EntityApi.deleteFn( + `${OVERARCHING_GOALS_BASEURL}/${id}` + ), +}; -// Fetch all OverarchingGoals associated with a particular User -const fetcherOverarchingGoals = async ( - url: string, - coachingSessionId: Id -): Promise => - axios - .get(url, { - params: { - coaching_session_id: coachingSessionId, - }, - withCredentials: true, - timeout: 5000, - headers: { - "X-Version": siteConfig.env.backendApiVersion, - }, - }) - .then((res) => res.data.data); +/** + * A custom React hook that fetches a list of overarching-goals for a specific user. + * + * This hook uses SWR to efficiently fetch, cache, and revalidate overarching-goal data. + * It automatically refreshes data when the component mounts. + * + * @param coachingSessionId The ID of the coachingSessionId whose overarching-goals should be fetched + * @returns An object containing: + * + * * overarchingGoals: Array of OverarchingGoal objects (empty array if data is not yet loaded) + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useOverarchingGoalList = (coachingSessionId: Id) => { + const { entities, isLoading, isError, refresh } = + EntityApi.useEntityList( + OVERARCHING_GOALS_BASEURL, + () => OverarchingGoalApi.list(coachingSessionId), + coachingSessionId + ); -/// A hook to retrieve all OverarchingGoals associated with coachingSessionId -export function useOverarchingGoals(coachingSessionId: Id) { - const { data, error, isLoading } = useSWR( - [ - `${siteConfig.env.backendServiceURL}/overarching_goals`, - coachingSessionId, - ], - ([url, _token]) => fetcherOverarchingGoals(url, coachingSessionId) - ); - const swrConfig = useSWRConfig(); - console.debug(`swrConfig: ${JSON.stringify(swrConfig)}`); + return { + overarchingGoals: entities, + isLoading, + isError, + refresh, + }; +}; - console.debug(`overarchingGoals data: ${JSON.stringify(data)}`); +/** + * A custom React hook that fetches a single overarching-goal by its ID. + * This hook uses SWR to efficiently fetch and cache overarching-goal data. + * It does not automatically revalidate the data on window focus, reconnect, or when data becomes stale. + * + * @param id The ID of the overarching-goal to fetch. If null or undefined, no fetch will occur. + * @returns An object containing: + * + * * overarchingGoal: The fetched OverarchingGoal object, or a default overarching-goal if not yet loaded + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useOverarchingGoal = (id: Id) => { + const url = id ? `${OVERARCHING_GOALS_BASEURL}/${id}` : null; + const fetcher = () => OverarchingGoalApi.get(id); + + const { entity, isLoading, isError, refresh } = + EntityApi.useEntity( + url, + fetcher, + defaultOverarchingGoal() + ); return { - overarchingGoals: Array.isArray(data) ? data : [], + overarchingGoal: entity, isLoading, - isError: error, + isError, + refresh, }; -} +}; -/// A hook to retrieve a single OverarchingGoal by a coachingSessionId -export function useOverarchingGoalByCoachingSessionId(coachingSessionId: Id) { - const { overarchingGoals, isLoading, isError } = - useOverarchingGoals(coachingSessionId); +/** + * A custom React hook that fetches a single overarching-goal by coaching session ID. + * This hook uses SWR to efficiently fetch and cache overarching-goal data. + * It does not automatically revalidate the data on window focus, reconnect, or when data becomes stale. + * + * @param coachingSessionId The coaching session ID of the overarching-goal to fetch. If null or undefined, no fetch will occur. + * @returns An object containing: + * + * * overarchingGoal: The fetched OverarchingGoal object, or a default overarching-goal if not yet loaded + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useOverarchingGoalBySession = (coachingSessionId: Id) => { + const { overarchingGoals, isLoading, isError, refresh } = + useOverarchingGoalList(coachingSessionId); return { overarchingGoal: overarchingGoals.length @@ -67,262 +169,34 @@ export function useOverarchingGoalByCoachingSessionId(coachingSessionId: Id) { : defaultOverarchingGoal(), isLoading, isError: isError, + refresh, }; -} - -interface ApiResponseOverarchingGoal { - status_code: number; - data: OverarchingGoal; -} - -// Fetcher for retrieving a single OverarchingGoal by its Id -const fetcherOverarchingGoal = async (url: string): Promise => - axios - .get(url, { - withCredentials: true, - timeout: 5000, - headers: { - "X-Version": siteConfig.env.backendApiVersion, - }, - }) - .then((res) => res.data.data); - -/// A hook to retrieve a single OverarchingGoal by its Id -export function useOverarchingGoal(overarchingGoalId: Id) { - const { data, error, isLoading } = useSWR( - `${siteConfig.env.backendServiceURL}/overarching_goals/${overarchingGoalId}`, - fetcherOverarchingGoal - ); - const swrConfig = useSWRConfig(); - console.debug(`swrConfig: ${JSON.stringify(swrConfig)}`); - - console.debug(`overarchingGoal data: ${JSON.stringify(data)}`); - - return { - overarchingGoal: data || defaultOverarchingGoal(), - isLoading, - isError: error, - }; -} - -export const fetchOverarchingGoalsByCoachingSessionId = async ( - coachingSessionId: Id -): Promise => { - const axios = require("axios"); - - var goals: OverarchingGoal[] = []; - var err: string = ""; - - await axios - .get(`${siteConfig.env.backendServiceURL}/overarching_goals`, { - params: { - coaching_session_id: coachingSessionId, - }, - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - }, - }) - .then(function (response: AxiosResponse) { - // handle success - var goals_data = response.data.data; - if (isOverarchingGoalArray(goals_data)) { - goals_data.forEach((goals_data: any) => { - goals.push(parseOverarchingGoal(goals_data)); - }); - } - }) - .catch(function (error: AxiosError) { - // handle error - if (error.response?.status == 401) { - err = "Retrieval of OverarchingGoals failed: unauthorized."; - } else if (error.response?.status == 404) { - err = - "Retrieval of OverarchingGoals failed: OverarchingGoals by coaching session Id (" + - coachingSessionId + - ") not found."; - } else { - err = - `Retrieval of OverarchingGoals by coaching session Id (` + - coachingSessionId + - `) failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return goals; -}; - -export const createOverarchingGoal = async ( - coaching_session_id: Id, - title: string, - body: string, - status: ItemStatus -): Promise => { - const axios = require("axios"); - - const newOverarchingGoalJson = { - coaching_session_id: coaching_session_id, - title: title, - body: body, - status: status, - }; - console.debug( - "newOverarchingGoalJson: " + JSON.stringify(newOverarchingGoalJson) - ); - // A full real action to be returned from the backend with the same body - var createdOverarchingGoal: OverarchingGoal = defaultOverarchingGoal(); - var err: string = ""; - - await axios - .post( - `${siteConfig.env.backendServiceURL}/overarching_goals`, - newOverarchingGoalJson, - { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - "Content-Type": "application/json", - }, - } - ) - .then(function (response: AxiosResponse) { - // handle success - const goal_data = response.data.data; - if (isOverarchingGoal(goal_data)) { - createdOverarchingGoal = parseOverarchingGoal(goal_data); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = "Creation of OverarchingGoal failed: unauthorized."; - } else if (error.response?.status == 500) { - err = "Creation of OverarchingGoal failed: internal server error."; - } else { - err = `Creation of new OverarchingGoal failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return createdOverarchingGoal; }; -export const updateOverarchingGoal = async ( - id: Id, - coaching_session_id: Id, - title: string, - body: string, - status: ItemStatus -): Promise => { - const axios = require("axios"); - - var updatedOverarchingGoal: OverarchingGoal = defaultOverarchingGoal(); - var err: string = ""; - - const toUpdateOverarchingGoalJson = { - id: id, - coaching_session_id: coaching_session_id, - title: title, - body: body, - status: status, - }; - console.debug( - "toUpdateOverarchingGoalJson: " + - JSON.stringify(toUpdateOverarchingGoalJson) +/** + * A custom React hook that provides mutation operations for overarching-goals with loading and error state management. + * This hook simplifies creating, updating, and deleting overarching-goals while handling loading states, + * error management, and cache invalidation automatically. + * + * @returns An object containing: + * create: Function to create a new overarching-goal + * update: Function to update an existing overarching-goal + * delete: Function to delete an overarching-goal + * isLoading: Boolean indicating if any operation is in progress + * error: Error object if the last operation failed, null otherwise + */ +/** + * Hook for overarching-goal mutations. + * Provides methods to create, update, and delete overarching-goal. + */ +export const useOverarchingGoalMutation = () => { + return EntityApi.useEntityMutation( + OVERARCHING_GOALS_BASEURL, + { + create: OverarchingGoalApi.create, + createNested: OverarchingGoalApi.createNested, + update: OverarchingGoalApi.update, + delete: OverarchingGoalApi.delete, + } ); - - await axios - .put( - `${siteConfig.env.backendServiceURL}/overarching_goals/${id}`, - toUpdateOverarchingGoalJson, - { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - "Content-Type": "application/json", - }, - } - ) - .then(function (response: AxiosResponse) { - // handle success - const goal_data = response.data.data; - if (isOverarchingGoal(goal_data)) { - updatedOverarchingGoal = parseOverarchingGoal(goal_data); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = "Update of OverarchingGoal failed: unauthorized."; - } else if (error.response?.status == 500) { - err = "Update of OverarchingGoal failed: internal server error."; - } else { - err = `Update of new OverarchingGoal failed: ${error.response?.statusText}`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return updatedOverarchingGoal; -}; - -export const deleteOverarchingGoal = async ( - id: Id -): Promise => { - const axios = require("axios"); - - var deletedOverarchingGoal: OverarchingGoal = defaultOverarchingGoal(); - var err: string = ""; - - await axios - .delete(`${siteConfig.env.backendServiceURL}/overarching_goals/${id}`, { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - "Content-Type": "application/json", - }, - }) - .then(function (response: AxiosResponse) { - // handle success - const goal_data = response.data.data; - if (isOverarchingGoal(goal_data)) { - deletedOverarchingGoal = parseOverarchingGoal(goal_data); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = "Deletion of OverarchingGoal failed: unauthorized."; - } else if (error.response?.status == 500) { - err = "Deletion of OverarchingGoal failed: internal server error."; - } else { - err = `Deletion of OverarchingGoal failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return deletedOverarchingGoal; }; From e520cc2be90c8fbbf1c55b7e017014074c02b3aa Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Fri, 28 Feb 2025 11:48:59 -0500 Subject: [PATCH 05/21] add CollaborationCursor --- src/components/ui/coaching-sessions/coaching-notes.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ui/coaching-sessions/coaching-notes.tsx b/src/components/ui/coaching-sessions/coaching-notes.tsx index c41d0709..fea0827c 100644 --- a/src/components/ui/coaching-sessions/coaching-notes.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes.tsx @@ -84,6 +84,7 @@ const useCollaborationProvider = (doc: Y.Doc) => { return { isLoading: isLoading || isSyncing, + userSession, isError, extensions, }; From 4afa804c5cf4776e9849cc24b22a39813e956d98 Mon Sep 17 00:00:00 2001 From: Caleb Bourg Date: Mon, 3 Mar 2025 07:38:34 -0500 Subject: [PATCH 06/21] add more console logging and check for existing provider --- src/components/ui/coaching-sessions/coaching-notes.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/ui/coaching-sessions/coaching-notes.tsx b/src/components/ui/coaching-sessions/coaching-notes.tsx index fea0827c..a136a08c 100644 --- a/src/components/ui/coaching-sessions/coaching-notes.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes.tsx @@ -15,6 +15,7 @@ import "@/styles/styles.scss"; const tiptapAppId = siteConfig.env.tiptapAppId; const useCollaborationProvider = (doc: Y.Doc) => { + console.log("useCollaborationProvider"); const { currentCoachingSessionId } = useCoachingSessionStateStore( (state) => state ); From 900fe9a9bcd8eb152daddcacc11bcd436ff3199e Mon Sep 17 00:00:00 2001 From: Zach Gavin <9595970+zgavin1@users.noreply.github.com> Date: Thu, 6 Mar 2025 17:59:10 -0600 Subject: [PATCH 07/21] Get collab cursor working --- src/components/ui/coaching-sessions/coaching-notes.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/ui/coaching-sessions/coaching-notes.tsx b/src/components/ui/coaching-sessions/coaching-notes.tsx index a136a08c..fea0827c 100644 --- a/src/components/ui/coaching-sessions/coaching-notes.tsx +++ b/src/components/ui/coaching-sessions/coaching-notes.tsx @@ -15,7 +15,6 @@ import "@/styles/styles.scss"; const tiptapAppId = siteConfig.env.tiptapAppId; const useCollaborationProvider = (doc: Y.Doc) => { - console.log("useCollaborationProvider"); const { currentCoachingSessionId } = useCoachingSessionStateStore( (state) => state ); From d15a6d11fd244df08bb6336b5d65941511254e28 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Mar 2025 12:17:31 -0500 Subject: [PATCH 08/21] Update entity-api to include an optional response data parsing/transformation function parameter to useEntityList() and listFn(). --- src/lib/api/entity-api.ts | 210 ++++++++++++++++++++++++++++++++------ 1 file changed, 178 insertions(+), 32 deletions(-) diff --git a/src/lib/api/entity-api.ts b/src/lib/api/entity-api.ts index 8e4c60f0..a2700f1e 100644 --- a/src/lib/api/entity-api.ts +++ b/src/lib/api/entity-api.ts @@ -2,7 +2,7 @@ import { siteConfig } from "@/site.config"; import { Id } from "@/types/general"; import axios from "axios"; import { useState } from "react"; -import useSWR, { SWRConfiguration, useSWRConfig } from "swr"; +import useSWR, { KeyedMutator, SWRConfiguration, useSWRConfig } from "swr"; export namespace EntityApi { interface ApiResponse { @@ -10,20 +10,67 @@ export namespace EntityApi { data: T; } - // Generic fetcher function for fetching Entity data - const fetcher = async (url: string, config?: any): Promise => - axios - .get>(url, { - withCredentials: true, - timeout: 5000, - headers: { - "X-Version": siteConfig.env.backendApiVersion, - }, - ...config, - }) - .then((res) => res.data.data); + /** + * Core fetcher function with optional data transformation capability. + * Handles API requests and response processing with SWR compatibility. + * + * @template T The raw data type from the API response + * @template U The transformed data type (defaults to T if no transform provided) + * @param url The endpoint URL for the API request + * @param config Optional request configuration object + * @param transform Optional transformation function to process the response data + * @returns Promise resolving to either raw or transformed data of type U + * + * @remarks This function: + * - Implements standard API request handling with axios + * - Includes default configuration (credentials, timeout, headers) + * - Supports optional response data transformation + * - Maintains SWR compatibility for data fetching + * - Preserves type safety through generic parameters + * + * The transformation is applied to the raw data before returning, allowing + * for data normalization or type conversion workflows. + */ + const fetcher = async ( + url: string, + config?: any, + transform?: (data: T) => U + ): Promise => { + const response = await axios.get>(url, { + withCredentials: true, + timeout: 5000, + headers: { + "X-Version": siteConfig.env.backendApiVersion, + }, + ...config, + }); + + const rawData = response.data.data; + return transform ? transform(rawData) : (rawData as unknown as U); + }; - // Type-safe mutation function for manipulating Entity data + /** + * Type-safe mutation handler for executing CRUD operations via HTTP methods. + * + * @template T Type of the request payload data (optional for DELETE) + * @template R Type of the response data structure + * @param method HTTP method to execute (POST, PUT, DELETE) + * @param url API endpoint URL for the operation + * @param data Optional payload data required for POST/PUT operations + * @returns Promise resolving to response data of type R + * @throws Error for invalid methods or missing required payload data + * + * @remarks This function: + * - Enforces RESTful conventions for mutation operations + * - Handles payload data type validation through generics + * - Applies consistent request configuration (credentials, timeout, headers) + * - Extracts and returns only the data portion from API responses + * - Throws explicit errors for invalid method/data combinations + * + * @usage + * - POST/PUT: Requires data payload matching type T + * - DELETE: Executes without payload data + */ const mutationFn = async ( method: "post" | "put" | "delete", url: string, @@ -52,15 +99,31 @@ export namespace EntityApi { }; /** - * Fetches a list of entities from the specified URL with optional parameters. + * Generic function to fetch and optionally transform a list of entities from an API endpoint. * - * @template R The type of entities to be returned in the array + * @template R The raw entity type returned by the API + * @template U The transformed entity type (defaults to R if no transform provided) * @param url The API endpoint URL to fetch data from * @param params Optional query parameters to include in the request - * @returns A Promise resolving to an array of entities of type R + * @param transform Optional transformation function applied to each entity in the response + * @returns A Promise resolving to an array of entities of type U + * + * @remarks This function: + * - Handles both raw and transformed data workflows + * - Applies transformations at the array level + * - Maintains type safety through generic parameters + * - Delegates actual fetching to the underlying fetcher utility */ - export const listFn = async (url: string, params: any): Promise => { - return fetcher(url, params); + export const listFn = async ( + url: string, + params: any, + transform?: (item: R) => U + ): Promise => { + return fetcher( + url, + params, + transform ? (data) => data.map(transform) : undefined + ); }; /** @@ -113,34 +176,117 @@ export namespace EntityApi { }; /** - * A generic hook for fetching lists of entities. + * A generic React hook for fetching lists of entities using SWR. * - * @template T The entity type - * @param url The API endpoint URL - * @param fetcher Function to fetch the list of entities - * @param params Additional parameters for the SWR key - * @param options SWR configuration options - * @returns Object with the entity list, loading state, error state, and refresh function + * @template T The type of the entity being fetched + * @param url The API endpoint URL to fetch data from + * @param fetcher A function that returns a promise resolving to an array of entities + * @param params Optional parameters to include in the request (used as part of the SWR key) + * @param options Optional SWR configuration options to customize the fetching behavior + * @returns An object containing: + * - entities: An array of fetched entities (empty array if data is not yet loaded) + * - isLoading: A boolean indicating whether the data is currently being fetched + * - isError: An error object if the fetch operation failed, undefined otherwise + * - refresh: A function to manually trigger a refresh of the data */ - export const useEntityList = ( + export function useEntityList( url: string, fetcher: () => Promise, params?: any, options?: SWRConfiguration - ) => { + ): { + entities: T[]; + isLoading: boolean; + isError: any; + refresh: KeyedMutator; + }; + + /** + * A generic React hook for fetching and transforming lists of entities using SWR. + * + * @template T The raw entity type returned by the API + * @template U The transformed entity type after applying the transformation + * @param url The API endpoint URL to fetch data from + * @param fetcher A function that returns a promise resolving to an array of raw entities + * @param transform A transformation function applied to each entity in the response list + * @param params Optional parameters to include in the request (used as part of the SWR key) + * @param options Optional SWR configuration options to customize fetching behavior + * @returns An object containing: + * - entities: An array of transformed entities (empty array if data not loaded) + * - isLoading: Boolean indicating if the data is currently being fetched + * - isError: Error object if fetch failed, undefined otherwise + * - refresh: Function to manually trigger refresh of transformed data + * + * @remarks This overload handles entity transformation workflows where each raw entity + * of type T is converted to type U through the provided transform function. + */ + export function useEntityList( + url: string, + fetcher: () => Promise, + transform: (item: T) => U, + params?: any, + options?: SWRConfiguration + ): { + entities: U[]; + isLoading: boolean; + isError: any; + refresh: KeyedMutator; + }; + + /** + * Implementation of an overloaded entity fetching hook with optional transformation. + * Handles both transformed and non-transformed data fetching workflows through parameter polymorphism. + * + * @template T The raw entity type from API response + * @template U The transformed entity type (defaults to T if no transformer provided) + * @param url API endpoint URL for resource location + * @param fetcher Data fetching function returning raw entity arrays + * @param transformOrParams Either a transformation function or request parameters + * @param paramsOrOptions Either request parameters or SWR configuration options + * @param options SWR configuration options when using transformation + * @returns Object containing transformed entities, loading state, error state, and refresh capability + * + * @remarks The parameter order and types enable multiple calling signatures: + * - Without transformation: (url, fetcher, params?, options?) + * - With transformation: (url, fetcher, transform, params?, options?) + * + * The implementation dynamically detects parameter types to maintain backward compatibility + * while supporting new transformation capabilities through method overloading. + */ + export function useEntityList( + url: string, + fetcher: () => Promise, + transformOrParams?: ((item: T) => U) | any, + paramsOrOptions?: any, + options?: SWRConfiguration + ) { + // Parameter type detection + const isTransform = typeof transformOrParams === "function"; + const transform = isTransform ? transformOrParams : undefined; + const params = isTransform ? paramsOrOptions : transformOrParams; + const swrOptions = isTransform ? options : paramsOrOptions; + + // SWR hook with proper typing const { data, error, isLoading, mutate } = useSWR( params ? [url, params] : url, fetcher, - { revalidateOnMount: true, ...options } + { revalidateOnMount: true, ...swrOptions } ); + // Data transformation logic + const entities = data + ? transform + ? data.map(transform) // Apply transformation if provided + : (data as unknown as U[]) // Type assertion for default case + : []; + return { - entities: Array.isArray(data) ? data : [], + entities, isLoading, isError: error, - refresh: mutate, + refresh: mutate as unknown as KeyedMutator, }; - }; + } /** * A generic hook for fetching and managing entity data. From 5d2f4f85f1d6b9c38565e537bce906c552751e0b Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Mar 2025 12:18:11 -0500 Subject: [PATCH 09/21] Update Agreements to use new EntityApi functions/hooks. --- .../ui/coaching-sessions/agreements-list.tsx | 127 +++--- .../overarching-goal-container.tsx | 84 ++-- src/lib/api/agreements.ts | 365 ++++++++---------- src/types/agreement.ts | 116 ++++-- 4 files changed, 318 insertions(+), 374 deletions(-) diff --git a/src/components/ui/coaching-sessions/agreements-list.tsx b/src/components/ui/coaching-sessions/agreements-list.tsx index 3ac22d61..f6847421 100644 --- a/src/components/ui/coaching-sessions/agreements-list.tsx +++ b/src/components/ui/coaching-sessions/agreements-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { @@ -17,9 +17,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { MoreHorizontal, ArrowUpDown, Save } from "lucide-react"; +import { MoreHorizontal, ArrowUpDown } from "lucide-react"; import { Id } from "@/types/general"; -import { fetchAgreementsByCoachingSessionId } from "@/lib/api/agreements"; +import { useAgreementList } from "@/lib/api/agreements"; import { Agreement, agreementToString } from "@/types/agreement"; import { DateTime } from "ts-luxon"; import { siteConfig } from "@/site.config"; @@ -43,8 +43,9 @@ const AgreementsList: React.FC<{ UpdatedAt = "updated_at", } - const [agreements, setAgreements] = useState([]); const [newAgreement, setNewAgreement] = useState(""); + const { agreements, isLoading, isError, refresh } = + useAgreementList(coachingSessionId); const [editingId, setEditingId] = useState(null); const [editBody, setEditBody] = useState(""); const [sortColumn, setSortColumn] = useState( @@ -52,25 +53,27 @@ const AgreementsList: React.FC<{ ); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); - const addAgreement = () => { + const addAgreement = async () => { if (newAgreement.trim() === "") return; - // Call the external onAgreementAdded handler function which should - // store this agreement in the backend database - onAgreementAdded(newAgreement) - .then((agreement) => { - console.trace( - "Newly created Agreement (onAgreementAdded): " + - agreementToString(agreement) - ); - setAgreements((prevAgreements) => [...prevAgreements, agreement]); - }) - .catch((err) => { - console.error("Failed to create new Agreement: " + err); - throw err; - }); + try { + // Call the external handler to create the agreement + const agreement = await onAgreementAdded(newAgreement); + + console.trace( + "Newly created Agreement (onAgreementAdded): " + + agreementToString(agreement) + ); - setNewAgreement(""); + // Refresh the agreements list from the hook + refresh(); + + // Clear input field + setNewAgreement(""); + } catch (err) { + console.error("Failed to create new Agreement: " + err); + throw err; + } }; const updateAgreement = async (id: Id, newBody: string) => { @@ -78,32 +81,18 @@ const AgreementsList: React.FC<{ if (body === "") return; try { - const updatedAgreements = await Promise.all( - agreements.map(async (agreement) => { - if (agreement.id === id) { - // Call the external onAgreementEdited handler function which should - // update the stored version of this agreement in the backend database - agreement = await onAgreementEdited(id, body) - .then((updatedAgreement) => { - console.trace( - "Updated Agreement (onAgreementUpdated): " + - agreementToString(updatedAgreement) - ); + // Update agreement in backend + const updatedAgreement = await onAgreementEdited(id, body); - return updatedAgreement; - }) - .catch((err) => { - console.error( - "Failed to update Agreement (id: " + id + "): " + err - ); - throw err; - }); - } - return agreement; - }) + console.trace( + "Updated Agreement (onAgreementUpdated): " + + agreementToString(updatedAgreement) ); - setAgreements(updatedAgreements); + // Refresh the agreements list from the hook + refresh(); + + // Reset editing UI state setEditingId(null); setEditBody(""); } catch (err) { @@ -112,23 +101,24 @@ const AgreementsList: React.FC<{ } }; - const deleteAgreement = (id: Id) => { + const deleteAgreement = async (id: Id) => { if (id === "") return; - // Call the external onAgreementDeleted handler function which should - // delete this agreement from the backend database - onAgreementDeleted(id) - .then((agreement) => { - console.trace( - "Deleted Agreement (onAgreementDeleted): " + - agreementToString(agreement) - ); - setAgreements(agreements.filter((agreement) => agreement.id !== id)); - }) - .catch((err) => { - console.error("Failed to Agreement (id: " + id + "): " + err); - throw err; - }); + try { + // Delete agreement in backend + const deletedAgreement = await onAgreementDeleted(id); + + console.trace( + "Deleted Agreement (onAgreementDeleted): " + + agreementToString(deletedAgreement) + ); + + // Refresh the agreements list from the hook + refresh(); + } catch (err) { + console.error("Failed to delete Agreement (id: " + id + "): " + err); + throw err; + } }; const sortAgreements = (column: keyof Agreement) => { @@ -149,27 +139,6 @@ const AgreementsList: React.FC<{ return 0; }); - useEffect(() => { - async function loadAgreements() { - if (!coachingSessionId) { - console.error( - "Failed to fetch Agreements since coachingSessionId is not set." - ); - return; - } - - await fetchAgreementsByCoachingSessionId(coachingSessionId) - .then((agreements) => { - console.debug("setAgreements: " + JSON.stringify(agreements)); - setAgreements(agreements); - }) - .catch(([err]) => { - console.error("Failed to fetch Agreements: " + err); - }); - } - loadAgreements(); - }, [coachingSessionId]); - return (
diff --git a/src/components/ui/coaching-sessions/overarching-goal-container.tsx b/src/components/ui/coaching-sessions/overarching-goal-container.tsx index 4f2b98fd..328405b6 100644 --- a/src/components/ui/coaching-sessions/overarching-goal-container.tsx +++ b/src/components/ui/coaching-sessions/overarching-goal-container.tsx @@ -5,12 +5,8 @@ import { ActionsList } from "@/components/ui/coaching-sessions/actions-list"; import { ItemStatus, Id } from "@/types/general"; import { Action } from "@/types/action"; import { AgreementsList } from "@/components/ui/coaching-sessions/agreements-list"; -import { Agreement } from "@/types/agreement"; -import { - createAgreement, - deleteAgreement, - updateAgreement, -} from "@/lib/api/agreements"; +import { Agreement, defaultAgreement } from "@/types/agreement"; +import { useAgreementMutation } from "@/lib/api/agreements"; import { createAction, deleteAction, updateAction } from "@/lib/api/actions"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { DateTime } from "ts-luxon"; @@ -36,40 +32,37 @@ const OverarchingGoalContainer: React.FC<{ ); const { overarchingGoal, isLoading, isError, refresh } = useOverarchingGoalBySession(currentCoachingSessionId); - const { create, update } = useOverarchingGoalMutation(); + const { create: createOverarchingGoal, update: updateOverarchingGoal } = + useOverarchingGoalMutation(); + const { + create: createAgreement, + update: updateAgreement, + delete: deleteAgreement, + } = useAgreementMutation(); const handleAgreementAdded = (body: string): Promise => { - // Calls the backend endpoint that creates and stores a full Agreement entity - return createAgreement(currentCoachingSessionId, userId, body) - .then((agreement) => { - return agreement; - }) - .catch((err) => { - console.error("Failed to create new Agreement: " + err); - throw err; - }); + const newAgreement: Agreement = { + ...defaultAgreement(), + coaching_session_id: currentCoachingSessionId, + user_id: userId, + body, + }; + return createAgreement(newAgreement); }; const handleAgreementEdited = (id: Id, body: string): Promise => { - return updateAgreement(id, currentCoachingSessionId, userId, body) - .then((agreement) => { - return agreement; - }) - .catch((err) => { - console.error("Failed to update Agreement (id: " + id + "): " + err); - throw err; - }); + const updatedAgreement: Agreement = { + ...defaultAgreement(), + id, + coaching_session_id: currentCoachingSessionId, + user_id: userId, + body, + }; + return updateAgreement(id, updatedAgreement); }; const handleAgreementDeleted = (id: Id): Promise => { - return deleteAgreement(id) - .then((agreement) => { - return agreement; - }) - .catch((err) => { - console.error("Failed to update Agreement (id: " + id + "): " + err); - throw err; - }); + return deleteAgreement(id); }; const handleActionAdded = ( @@ -116,36 +109,19 @@ const OverarchingGoalContainer: React.FC<{ }; const handleGoalChange = async (newGoal: OverarchingGoal) => { - // console.trace( - // "handleGoalChange (goal title to set/update): " + newGoal.title - // ); - // console.trace( - // "handleGoalChange (goal to set/update): " + - // overarchingGoalToString(newGoal) - // ); - // console.trace( - // "handleGoalChange (overarchingGoal.id , currentCoachingSessionId set/update): " + - // overarchingGoal.id + - // ", " + - // currentCoachingSessionId - // ); - try { if (currentCoachingSessionId) { if (overarchingGoal.id) { - // console.debug( - // "Update existing Overarching Goal with id: " + overarchingGoal.id - // ); - const responseGoal = await update(overarchingGoal.id, newGoal); + const responseGoal = await updateOverarchingGoal( + overarchingGoal.id, + newGoal + ); console.trace( "Updated Overarching Goal: " + overarchingGoalToString(responseGoal) ); } else if (!overarchingGoal.id) { newGoal.coaching_session_id = currentCoachingSessionId; - // console.trace( - // "Creating new Overarching Goal: " + overarchingGoalToString(newGoal) - // ); - const responseGoal = await create(newGoal); + const responseGoal = await createOverarchingGoal(newGoal); console.trace( "Newly created Overarching Goal: " + overarchingGoalToString(responseGoal) diff --git a/src/lib/api/agreements.ts b/src/lib/api/agreements.ts index 6a8b1e31..7953b841 100644 --- a/src/lib/api/agreements.ts +++ b/src/lib/api/agreements.ts @@ -1,217 +1,182 @@ // Interacts with the agreement endpoints import { siteConfig } from "@/site.config"; +import { Id } from "@/types/general"; import { Agreement, defaultAgreement, - isAgreement, - isAgreementArray, - parseAgreement, + transformAgreement, } from "@/types/agreement"; -import { Id } from "@/types/general"; -import { AxiosError, AxiosResponse } from "axios"; - -export const fetchAgreementsByCoachingSessionId = async ( - coachingSessionId: Id -): Promise => { - const axios = require("axios"); - - var agreements: Agreement[] = []; - var err: string = ""; - - const data = await axios - .get(`${siteConfig.env.backendServiceURL}/agreements`, { - params: { - coaching_session_id: coachingSessionId, - }, - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - }, - }) - .then(function (response: AxiosResponse) { - // handle success - var agreements_data = response.data.data; - if (isAgreementArray(agreements_data)) { - agreements_data.forEach((agreements_data: any) => { - agreements.push(parseAgreement(agreements_data)); - }); - } - }) - .catch(function (error: AxiosError) { - // handle error - if (error.response?.status == 401) { - err = "Retrieval of Agreements failed: unauthorized."; - } else if (error.response?.status == 404) { - err = - "Retrieval of Agreements failed: Agreements by coaching session Id (" + - coachingSessionId + - ") not found."; - } else { - err = - `Retrieval of Agreements by coaching session Id (` + - coachingSessionId + - `) failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return agreements; +import { EntityApi } from "./entity-api"; + +const AGREEMENTS_BASEURL: string = `${siteConfig.env.backendServiceURL}/agreements`; + +/** + * API client for agreement-related operations. + * + * This object provides a collection of functions for interacting with the agreement endpoints + * on the backend service. It handles the HTTP requests and response parsing for all CRUD operations. + */ +export const AgreementApi = { + /* + * Fetches a list of agreements associated with a specific user. + * + * @param userId The ID of the user whose agreement should be retrieved + * @returns Promise resolving to an array of Agreement objects + */ + list: async (coachingSessionId: Id): Promise => + EntityApi.listFn(AGREEMENTS_BASEURL, { + params: { coaching_session_id: coachingSessionId }, + }), + + /** + * Fetches a single agreement by its ID. + * + * @param id The ID of the agreement to retrieve + * @returns Promise resolving to the Agreement object + */ + get: async (id: Id): Promise => + EntityApi.getFn(`${AGREEMENTS_BASEURL}/${id}`), + + /** + * Creates a new agreement. + * + * @param agreement The agreement data to create + * @returns Promise resolving to the created Agreement object + */ + create: async (agreement: Agreement): Promise => + EntityApi.createFn(AGREEMENTS_BASEURL, agreement), + + createNested: async (_id: Id, _entity: Agreement): Promise => { + throw new Error("Create nested operation not implemented"); + }, + + /** + * Updates an existing agreement. + * + * @param id The ID of the agreement to update + * @param agreement The updated agreement data + * @returns Promise resolving to the updated Agreement object + */ + update: async (id: Id, agreement: Agreement): Promise => + EntityApi.updateFn( + `${AGREEMENTS_BASEURL}/${id}`, + agreement + ), + + /** + * Deletes an agreement. + * + * @param id The ID of the agreement to delete + * @returns Promise resolving to the deleted Agreement object + */ + delete: async (id: Id): Promise => + EntityApi.deleteFn(`${AGREEMENTS_BASEURL}/${id}`), }; -export const createAgreement = async ( - coaching_session_id: Id, - user_id: Id, - body: string -): Promise => { - const axios = require("axios"); - - const newAgreementJson = { - coaching_session_id: coaching_session_id, - user_id: user_id, - body: body, +/** + * A custom React hook that fetches a list of agreements for a specific user. + * + * This hook uses SWR to efficiently fetch, cache, and revalidate agreement data. + * It automatically refreshes data when the component mounts. + * + * @param coachingSessionId The ID of the coachingSessionId whose agreements should be fetched + * @returns An object containing: + * + * * agreements: Array of Agreement objects (empty array if data is not yet loaded) + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useAgreementList = (coachingSessionId: Id) => { + const { entities, isLoading, isError, refresh } = EntityApi.useEntityList< + Agreement, + Agreement + >( + AGREEMENTS_BASEURL, + () => AgreementApi.list(coachingSessionId), + transformAgreement, + coachingSessionId + ); + + return { + agreements: entities, + isLoading, + isError, + refresh, }; - console.debug("newAgreementJson: " + JSON.stringify(newAgreementJson)); - // A full real note to be returned from the backend with the same body - var createdAgreement: Agreement = defaultAgreement(); - var err: string = ""; - - const data = await axios - .post(`${siteConfig.env.backendServiceURL}/agreements`, newAgreementJson, { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - "Content-Type": "application/json", - }, - }) - .then(function (response: AxiosResponse) { - // handle success - const agreement_data = response.data.data; - if (isAgreement(agreement_data)) { - createdAgreement = parseAgreement(agreement_data); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = "Creation of Agreement failed: unauthorized."; - } else if (error.response?.status == 500) { - err = "Creation of Agreement failed: internal server error."; - } else { - err = `Creation of Agreement failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return createdAgreement; }; -export const updateAgreement = async ( - id: Id, - user_id: Id, - coaching_session_id: Id, - body: string -): Promise => { - const axios = require("axios"); - - var updatedAgreement: Agreement = defaultAgreement(); - var err: string = ""; - - const newAgreementJson = { - coaching_session_id: coaching_session_id, - user_id: user_id, - body: body, +/** + * A custom React hook that fetches a single agreement by its ID. + * This hook uses SWR to efficiently fetch and cache agreement data. + * It does not automatically revalidate the data on window focus, reconnect, or when data becomes stale. + * + * @param id The ID of the agreement to fetch. If null or undefined, no fetch will occur. + * @returns An object containing: + * + * * agreement: The fetched Agreement object, or a default agreement if not yet loaded + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useAgreement = (id: Id) => { + const url = id ? `${AGREEMENTS_BASEURL}/${id}` : null; + const fetcher = () => AgreementApi.get(id); + + const { entity, isLoading, isError, refresh } = + EntityApi.useEntity(url, fetcher, defaultAgreement()); + + return { + agreement: entity, + isLoading, + isError, + refresh, }; +}; - const data = await axios - .put( - `${siteConfig.env.backendServiceURL}/agreements/${id}`, - newAgreementJson, - { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - "Content-Type": "application/json", - }, - } - ) - .then(function (response: AxiosResponse) { - // handle success - const agreement_data = response.data.data; - if (isAgreement(agreement_data)) { - updatedAgreement = parseAgreement(agreement_data); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = "Update of Agreement failed: unauthorized."; - } else if (error.response?.status == 500) { - err = "Update of Agreement failed: internal server error."; - } else { - err = `Update of new Agreement failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return updatedAgreement; +/** + * A custom React hook that fetches a single agreement by coaching session ID. + * This hook uses SWR to efficiently fetch and cache agreement data. + * It does not automatically revalidate the data on window focus, reconnect, or when data becomes stale. + * + * @param coachingSessionId The coaching session ID of the agreement to fetch. If null or undefined, no fetch will occur. + * @returns An object containing: + * + * * agreement: The fetched Agreement object, or a default agreement if not yet loaded + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useAgreementBySession = (coachingSessionId: Id) => { + const { agreements, isLoading, isError, refresh } = + useAgreementList(coachingSessionId); + + return { + agreement: agreements.length ? agreements[0] : defaultAgreement(), + isLoading, + isError: isError, + refresh, + }; }; -export const deleteAgreement = async (id: Id): Promise => { - const axios = require("axios"); - - var deletedAgreement: Agreement = defaultAgreement(); - var err: string = ""; - - const data = await axios - .delete(`${siteConfig.env.backendServiceURL}/agreements/${id}`, { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - "Content-Type": "application/json", - }, - }) - .then(function (response: AxiosResponse) { - // handle success - const agreement_data = response.data.data; - if (isAgreement(agreement_data)) { - deletedAgreement = parseAgreement(agreement_data); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = "Deletion of Agreement failed: unauthorized."; - } else if (error.response?.status == 500) { - err = "Deletion of Agreement failed: internal server error."; - } else { - err = `Deletion of new Agreement failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return deletedAgreement; +/** + * A custom React hook that provides mutation operations for agreements with loading and error state management. + * This hook simplifies creating, updating, and deleting agreements while handling loading states, + * error management, and cache invalidation automatically. + * + * @returns An object containing: + * create: Function to create a new agreement + * update: Function to update an existing agreement + * delete: Function to delete an agreement + * isLoading: Boolean indicating if any operation is in progress + * error: Error object if the last operation failed, null otherwise + */ +export const useAgreementMutation = () => { + return EntityApi.useEntityMutation(AGREEMENTS_BASEURL, { + create: AgreementApi.create, + createNested: AgreementApi.createNested, + update: AgreementApi.update, + delete: AgreementApi.delete, + }); }; diff --git a/src/types/agreement.ts b/src/types/agreement.ts index b57be309..4815d01d 100644 --- a/src/types/agreement.ts +++ b/src/types/agreement.ts @@ -5,19 +5,44 @@ import { Id, SortOrder } from "@/types/general"; // entity::agreements::Model export interface Agreement { id: Id; - coaching_session_id: Id, - body?: string, - user_id: Id, + coaching_session_id: Id; + body?: string; + user_id: Id; created_at: DateTime; updated_at: DateTime; } +// Type-safe transformation function with runtime validation that ensures +// that raw ISO date time stamps are transformed into valid ts-luxon DateTime +// instances. +export const transformAgreement = (data: any): any => { + // Return early for non-objects + if (typeof data !== "object" || data === null) return data; + + // Create a new object with transformed dates + const transformed: Record = { ...data }; + + // Helper function for safe date conversion + const convertDate = (field: string) => { + if (typeof transformed[field] === "string") { + const dt = DateTime.fromISO(transformed[field]); + transformed[field] = dt.isValid ? dt : transformed[field]; + } + }; + + // Convert known date + time fields + convertDate("created_at"); + convertDate("updated_at"); + + return transformed; +}; + // The main purpose of having this parsing function is to be able to parse the // returned DateTimeWithTimeZone (Rust type) string into something that ts-luxon // will agree to work with internally. export function parseAgreement(data: any): Agreement { if (!isAgreement(data)) { - throw new Error('Invalid Agreement object data'); + throw new Error("Invalid Agreement object data"); } return { id: data.id, @@ -30,56 +55,65 @@ export function parseAgreement(data: any): Agreement { } export function isAgreement(value: unknown): value is Agreement { - if (!value || typeof value !== "object") { - return false; - } - const object = value as Record; + if (!value || typeof value !== "object") { + return false; + } + const object = value as Record; - return ( - (typeof object.id === "string" && + return ( + (typeof object.id === "string" && typeof object.coaching_session_id === "string" && typeof object.user_id === "string" && typeof object.created_at === "string" && - typeof object.updated_at === "string") || - typeof object.body === "string" // body is optional - ); - } + typeof object.updated_at === "string") || + typeof object.body === "string" // body is optional + ); +} export function isAgreementArray(value: unknown): value is Agreement[] { return Array.isArray(value) && value.every(isAgreement); } -export function sortAgreementArray(agreements: Agreement[], order: SortOrder): Agreement[] { +export function sortAgreementArray( + agreements: Agreement[], + order: SortOrder +): Agreement[] { if (order == SortOrder.Ascending) { - agreements.sort((a, b) => - new Date(a.updated_at.toString()).getTime() - new Date(b.updated_at.toString()).getTime()); + agreements.sort( + (a, b) => + new Date(a.updated_at.toString()).getTime() - + new Date(b.updated_at.toString()).getTime() + ); } else if (order == SortOrder.Descending) { - agreements.sort((a, b) => - new Date(b.updated_at.toString()).getTime() - new Date(a.updated_at.toString()).getTime()); + agreements.sort( + (a, b) => + new Date(b.updated_at.toString()).getTime() - + new Date(a.updated_at.toString()).getTime() + ); } return agreements; } export function defaultAgreement(): Agreement { - const now = DateTime.now(); - return { - id: "", - coaching_session_id: "", - body: "", - user_id: "", - created_at: now, - updated_at: now, - }; - } - - export function defaultAgreements(): Agreement[] { - return [defaultAgreement()]; - } - - export function agreementToString(agreement: Agreement): string { - return JSON.stringify(agreement); - } - - export function agreementsToString(agreements: Agreement[]): string { - return JSON.stringify(agreements); - } \ No newline at end of file + const now = DateTime.now(); + return { + id: "", + coaching_session_id: "", + body: "", + user_id: "", + created_at: now, + updated_at: now, + }; +} + +export function defaultAgreements(): Agreement[] { + return [defaultAgreement()]; +} + +export function agreementToString(agreement: Agreement): string { + return JSON.stringify(agreement); +} + +export function agreementsToString(agreements: Agreement[]): string { + return JSON.stringify(agreements); +} From 26eabe5a7b3147e64ec26f1ed3efd5b654fccdb5 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Mar 2025 14:50:30 -0500 Subject: [PATCH 10/21] Remove the parseAgreement() function that is replaced by the transformAgreement function --- src/types/agreement.ts | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/src/types/agreement.ts b/src/types/agreement.ts index 4815d01d..37fe7acc 100644 --- a/src/types/agreement.ts +++ b/src/types/agreement.ts @@ -37,23 +37,6 @@ export const transformAgreement = (data: any): any => { return transformed; }; -// The main purpose of having this parsing function is to be able to parse the -// returned DateTimeWithTimeZone (Rust type) string into something that ts-luxon -// will agree to work with internally. -export function parseAgreement(data: any): Agreement { - if (!isAgreement(data)) { - throw new Error("Invalid Agreement object data"); - } - return { - id: data.id, - coaching_session_id: data.coaching_session_id, - body: data.body, - user_id: data.user_id, - created_at: DateTime.fromISO(data.created_at.toString()), - updated_at: DateTime.fromISO(data.updated_at.toString()), - }; -} - export function isAgreement(value: unknown): value is Agreement { if (!value || typeof value !== "object") { return false; From 9abbadf1b56321e68ceb93f34d9249052361696a Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Mar 2025 15:38:57 -0500 Subject: [PATCH 11/21] Refactor Actions to use the new API/hook pattern. Also move the new transformAgreement() function into general.ts as a generic, reusable function called transformEntityDates() --- .../ui/coaching-sessions/actions-list.tsx | 125 +++--- .../overarching-goal-container.tsx | 54 +-- src/lib/api/actions.ts | 373 ++++++++---------- src/lib/api/agreements.ts | 9 +- src/types/action.ts | 20 - src/types/general.ts | 26 ++ 6 files changed, 270 insertions(+), 337 deletions(-) diff --git a/src/components/ui/coaching-sessions/actions-list.tsx b/src/components/ui/coaching-sessions/actions-list.tsx index 58da7f7d..3bbc7ca5 100644 --- a/src/components/ui/coaching-sessions/actions-list.tsx +++ b/src/components/ui/coaching-sessions/actions-list.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { @@ -37,7 +37,7 @@ import { Id, stringToActionStatus, } from "@/types/general"; -import { fetchActionsByCoachingSessionId } from "@/lib/api/actions"; +import { useActionList } from "@/lib/api/actions"; import { DateTime } from "ts-luxon"; import { siteConfig } from "@/site.config"; import { Action, actionToString } from "@/types/action"; @@ -74,7 +74,8 @@ const ActionsList: React.FC<{ UpdatedAt = "updated_at", } - const [actions, setActions] = useState([]); + const { actions, isLoading, isError, refresh } = + useActionList(coachingSessionId); const [newBody, setNewBody] = useState(""); const [newStatus, setNewStatus] = useState(ItemStatus.NotStarted); const [newDueBy, setNewDueBy] = useState(DateTime.now()); @@ -89,26 +90,28 @@ const ActionsList: React.FC<{ ); const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); - const addAction = () => { + const addAction = async () => { if (newBody.trim() === "") return; - // Call the external onActionAdded handler function which should - // store this action in the backend database - onActionAdded(newBody, newStatus, newDueBy) - .then((action) => { - console.trace( - "Newly created Action (onActionAdded): " + actionToString(action) - ); - setActions((prevActions) => [...prevActions, action]); - }) - .catch((err) => { - console.error("Failed to create new Action: " + err); - throw err; - }); + try { + // Call the external handler to create the action + const action = await onActionAdded(newBody, newStatus, newDueBy); + + console.trace( + "Newly created Action (onActionAdded): " + actionToString(action) + ); - setNewBody(""); - setNewStatus(ItemStatus.NotStarted); - setNewDueBy(DateTime.now()); + // Refresh the actions list from the hook + refresh(); + + // Clear input fields + setNewBody(""); + setNewStatus(ItemStatus.NotStarted); + setNewDueBy(DateTime.now()); + } catch (err) { + console.error("Failed to create new Action: " + err); + throw err; + } }; const updateAction = async ( @@ -121,32 +124,18 @@ const ActionsList: React.FC<{ if (body === "") return; try { - const updatedActions = await Promise.all( - actions.map(async (action) => { - if (action.id === id) { - // Call the external onActionEdited handler function which should - // update the stored version of this action in the backend database - action = await onActionEdited(id, body, newStatus, newDueBy) - .then((updatedAction) => { - console.trace( - "Updated Action (onActionUpdated): " + - actionToString(updatedAction) - ); + // Call the external onActionEdited handler function which should + // update the stored version of this action in the backend database + const updatedAction = await onActionEdited(id, body, newStatus, newDueBy); - return updatedAction; - }) - .catch((err) => { - console.error( - "Failed to update Action (id: " + id + "): " + err - ); - throw err; - }); - } - return action; - }) + console.trace( + "Updated Action (onActionUpdated): " + actionToString(updatedAction) ); - setActions(updatedActions); + // Refresh the actions list from the hook + refresh(); + + // Reset editing UI state setEditingId(null); setEditBody(""); setEditStatus(ItemStatus.NotStarted); @@ -157,22 +146,23 @@ const ActionsList: React.FC<{ } }; - const deleteAction = (id: Id) => { + const deleteAction = async (id: Id) => { if (id === "") return; - // Call the external onActionDeleted handler function which should - // delete this action from the backend database - onActionDeleted(id) - .then((action) => { - console.trace( - "Deleted Action (onActionDeleted): " + actionToString(action) - ); - setActions(actions.filter((action) => action.id !== id)); - }) - .catch((err) => { - console.error("Failed to Action (id: " + id + "): " + err); - throw err; - }); + try { + // Delete action in backend + const deletedAction = await onActionDeleted(id); + + console.trace( + "Deleted Action (onActionDeleted): " + actionToString(deletedAction) + ); + + // Refresh the actions list from the hook + refresh(); + } catch (err) { + console.error("Failed to delete Action (id: " + id + "): " + err); + throw err; + } }; const sortActions = (column: keyof Action) => { @@ -193,27 +183,6 @@ const ActionsList: React.FC<{ return 0; }); - useEffect(() => { - async function loadActions() { - if (!coachingSessionId) { - console.error( - "Failed to fetch Actions since coachingSessionId is not set." - ); - return; - } - - await fetchActionsByCoachingSessionId(coachingSessionId) - .then((actions) => { - console.debug("setActions: " + JSON.stringify(actions)); - setActions(actions); - }) - .catch(([err]) => { - console.error("Failed to fetch Actions: " + err); - }); - } - loadActions(); - }, [coachingSessionId]); - return (
diff --git a/src/components/ui/coaching-sessions/overarching-goal-container.tsx b/src/components/ui/coaching-sessions/overarching-goal-container.tsx index 328405b6..6162c479 100644 --- a/src/components/ui/coaching-sessions/overarching-goal-container.tsx +++ b/src/components/ui/coaching-sessions/overarching-goal-container.tsx @@ -3,11 +3,11 @@ import { useState } from "react"; import { ActionsList } from "@/components/ui/coaching-sessions/actions-list"; import { ItemStatus, Id } from "@/types/general"; -import { Action } from "@/types/action"; +import { Action, defaultAction } from "@/types/action"; import { AgreementsList } from "@/components/ui/coaching-sessions/agreements-list"; import { Agreement, defaultAgreement } from "@/types/agreement"; import { useAgreementMutation } from "@/lib/api/agreements"; -import { createAction, deleteAction, updateAction } from "@/lib/api/actions"; +import { useActionMutation } from "@/lib/api/actions"; import { Collapsible, CollapsibleContent } from "@/components/ui/collapsible"; import { DateTime } from "ts-luxon"; import { siteConfig } from "@/site.config"; @@ -40,6 +40,12 @@ const OverarchingGoalContainer: React.FC<{ delete: deleteAgreement, } = useAgreementMutation(); + const { + create: createAction, + update: updateAction, + delete: deleteAction, + } = useActionMutation(); + const handleAgreementAdded = (body: string): Promise => { const newAgreement: Agreement = { ...defaultAgreement(), @@ -71,14 +77,15 @@ const OverarchingGoalContainer: React.FC<{ dueBy: DateTime ): Promise => { // Calls the backend endpoint that creates and stores a full Action entity - return createAction(currentCoachingSessionId, body, status, dueBy) - .then((action) => { - return action; - }) - .catch((err) => { - console.error("Failed to create new Action: " + err); - throw err; - }); + const newAction: Action = { + ...defaultAction(), + coaching_session_id: currentCoachingSessionId, + user_id: userId, + body, + status, + due_by: dueBy, + }; + return createAction(newAction); }; const handleActionEdited = ( @@ -87,25 +94,20 @@ const OverarchingGoalContainer: React.FC<{ status: ItemStatus, dueBy: DateTime ): Promise => { - return updateAction(id, currentCoachingSessionId, body, status, dueBy) - .then((action) => { - return action; - }) - .catch((err) => { - console.error("Failed to update Action (id: " + id + "): " + err); - throw err; - }); + const updatedAction: Action = { + ...defaultAction(), + id, + coaching_session_id: currentCoachingSessionId, + user_id: userId, + body, + status, + due_by: dueBy, + }; + return updateAction(id, updatedAction); }; const handleActionDeleted = (id: Id): Promise => { - return deleteAction(id) - .then((action) => { - return action; - }) - .catch((err) => { - console.error("Failed to update Action (id: " + id + "): " + err); - throw err; - }); + return deleteAction(id); }; const handleGoalChange = async (newGoal: OverarchingGoal) => { diff --git a/src/lib/api/actions.ts b/src/lib/api/actions.ts index c868ebb2..6fb4e9d4 100644 --- a/src/lib/api/actions.ts +++ b/src/lib/api/actions.ts @@ -1,219 +1,178 @@ // Interacts with the action endpoints import { siteConfig } from "@/site.config"; -import { - Action, - defaultAction, - isAction, - isActionArray, - parseAction, -} from "@/types/action"; -import { ItemStatus, Id } from "@/types/general"; -import { AxiosError, AxiosResponse } from "axios"; -import { DateTime } from "ts-luxon"; - -export const fetchActionsByCoachingSessionId = async ( - coachingSessionId: Id -): Promise => { - const axios = require("axios"); - - var actions: Action[] = []; - var err: string = ""; - - const data = await axios - .get(`${siteConfig.env.backendServiceURL}/actions`, { - params: { - coaching_session_id: coachingSessionId, - }, - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - }, - }) - .then(function (response: AxiosResponse) { - // handle success - var actions_data = response.data.data; - if (isActionArray(actions_data)) { - actions_data.forEach((actions_data: any) => { - actions.push(parseAction(actions_data)); - }); - } - }) - .catch(function (error: AxiosError) { - // handle error - if (error.response?.status == 401) { - err = "Retrieval of Actions failed: unauthorized."; - } else if (error.response?.status == 404) { - err = - "Retrieval of Actions failed: Actions by coaching session Id (" + - coachingSessionId + - ") not found."; - } else { - err = - `Retrieval of Actions by coaching session Id (` + - coachingSessionId + - `) failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return actions; +import { Id, transformEntityDates } from "@/types/general"; +import { Action, defaultAction } from "@/types/action"; +import { EntityApi } from "./entity-api"; + +const ACTIONS_BASEURL: string = `${siteConfig.env.backendServiceURL}/actions`; + +/** + * API client for action-related operations. + * + * This object provides a collection of functions for interacting with the action endpoints + * on the backend service. It handles the HTTP requests and response parsing for all CRUD operations. + */ +export const ActionApi = { + /* + * Fetches a list of actions associated with a specific user. + * + * @param userId The ID of the user whose action should be retrieved + * @returns Promise resolving to an array of Action objects + */ + list: async (coachingSessionId: Id): Promise => + EntityApi.listFn(ACTIONS_BASEURL, { + params: { coaching_session_id: coachingSessionId }, + }), + + /** + * Fetches a single action by its ID. + * + * @param id The ID of the action to retrieve + * @returns Promise resolving to the Action object + */ + get: async (id: Id): Promise => + EntityApi.getFn(`${ACTIONS_BASEURL}/${id}`), + + /** + * Creates a new action. + * + * @param action The action data to create + * @returns Promise resolving to the created Action object + */ + create: async (action: Action): Promise => + EntityApi.createFn(ACTIONS_BASEURL, action), + + createNested: async (_id: Id, _entity: Action): Promise => { + throw new Error("Create nested operation not implemented"); + }, + + /** + * Updates an existing action. + * + * @param id The ID of the action to update + * @param action The updated action data + * @returns Promise resolving to the updated Action object + */ + update: async (id: Id, action: Action): Promise => + EntityApi.updateFn(`${ACTIONS_BASEURL}/${id}`, action), + + /** + * Deletes an action. + * + * @param id The ID of the action to delete + * @returns Promise resolving to the deleted Action object + */ + delete: async (id: Id): Promise => + EntityApi.deleteFn(`${ACTIONS_BASEURL}/${id}`), }; -export const createAction = async ( - coaching_session_id: Id, - body: string, - status: ItemStatus, - due_by: DateTime -): Promise => { - const axios = require("axios"); - - const newActionJson = { - coaching_session_id: coaching_session_id, - body: body, - due_by: due_by, - status: status, +/** + * A custom React hook that fetches a list of actions for a specific coaching session. + * + * This hook uses SWR to efficiently fetch, cache, and revalidate action data. + * It automatically refreshes data when the component mounts. + * + * @param coachingSessionId The ID of the coachingSessionId under which actions should be fetched + * @returns An object containing: + * + * * actions: Array of Action objects (empty array if data is not yet loaded) + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useActionList = (coachingSessionId: Id) => { + const { entities, isLoading, isError, refresh } = EntityApi.useEntityList< + Action, + Action + >( + ACTIONS_BASEURL, + () => ActionApi.list(coachingSessionId), + transformEntityDates, + coachingSessionId + ); + + return { + actions: entities, + isLoading, + isError, + refresh, }; - console.debug("newActionJson: " + JSON.stringify(newActionJson)); - // A full real action to be returned from the backend with the same body - var createdAction: Action = defaultAction(); - var err: string = ""; - - const data = await axios - .post(`${siteConfig.env.backendServiceURL}/actions`, newActionJson, { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - "Content-Type": "application/json", - }, - }) - .then(function (response: AxiosResponse) { - // handle success - const action_data = response.data.data; - if (isAction(action_data)) { - createdAction = parseAction(action_data); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = "Creation of Action failed: unauthorized."; - } else if (error.response?.status == 500) { - err = "Creation of Action failed: internal server error."; - } else { - err = `Creation of new Action failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return createdAction; }; -export const updateAction = async ( - id: Id, - coaching_session_id: Id, - body: string, - status: ItemStatus, - due_by: DateTime -): Promise => { - const axios = require("axios"); - - var updatedAction: Action = defaultAction(); - var err: string = ""; - - const newActionJson = { - coaching_session_id: coaching_session_id, - body: body, - status: status, - due_by: due_by, +/** + * A custom React hook that fetches a single action by its ID. + * This hook uses SWR to efficiently fetch and cache action data. + * It does not automatically revalidate the data on window focus, reconnect, or when data becomes stale. + * + * @param id The ID of the action to fetch. If null or undefined, no fetch will occur. + * @returns An object containing: + * + * * action: The fetched Action object, or a default action if not yet loaded + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useAction = (id: Id) => { + const url = id ? `${ACTIONS_BASEURL}/${id}` : null; + const fetcher = () => ActionApi.get(id); + + const { entity, isLoading, isError, refresh } = EntityApi.useEntity( + url, + fetcher, + defaultAction() + ); + + return { + action: entity, + isLoading, + isError, + refresh, }; - console.debug("newActionJson: " + JSON.stringify(newActionJson)); - - const data = await axios - .put(`${siteConfig.env.backendServiceURL}/actions/${id}`, newActionJson, { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - "Content-Type": "application/json", - }, - }) - .then(function (response: AxiosResponse) { - // handle success - const action_data = response.data.data; - if (isAction(action_data)) { - updatedAction = parseAction(action_data); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = "Update of Action failed: unauthorized."; - } else if (error.response?.status == 500) { - err = "Update of Action failed: internal server error."; - } else { - err = `Update of new Action failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return updatedAction; }; -export const deleteAction = async (id: Id): Promise => { - const axios = require("axios"); - - var deletedAction: Action = defaultAction(); - var err: string = ""; - - const data = await axios - .delete(`${siteConfig.env.backendServiceURL}/actions/${id}`, { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - "Content-Type": "application/json", - }, - }) - .then(function (response: AxiosResponse) { - // handle success - const action_data = response.data.data; - if (isAction(action_data)) { - deletedAction = parseAction(action_data); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = "Deletion of Action failed: unauthorized."; - } else if (error.response?.status == 500) { - err = "Deletion of Action failed: internal server error."; - } else { - err = `Deletion of Action failed.`; - } - }); - - if (err) { - console.error(err); - throw err; - } - - return deletedAction; +/** + * A custom React hook that fetches a single action by coaching session ID. + * This hook uses SWR to efficiently fetch and cache action data. + * It does not automatically revalidate the data on window focus, reconnect, or when data becomes stale. + * + * @param coachingSessionId The coaching session ID of the action to fetch. If null or undefined, no fetch will occur. + * @returns An object containing: + * + * * action: The fetched Action object, or a default action if not yet loaded + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useActionBySession = (coachingSessionId: Id) => { + const { actions, isLoading, isError, refresh } = + useActionList(coachingSessionId); + + return { + action: actions.length ? actions[0] : defaultAction(), + isLoading, + isError: isError, + refresh, + }; +}; + +/** + * A custom React hook that provides mutation operations for actions with loading and error state management. + * This hook simplifies creating, updating, and deleting actions while handling loading states, + * error management, and cache invalidation automatically. + * + * @returns An object containing: + * create: Function to create a new action + * update: Function to update an existing action + * delete: Function to delete an action + * isLoading: Boolean indicating if any operation is in progress + * error: Error object if the last operation failed, null otherwise + */ +export const useActionMutation = () => { + return EntityApi.useEntityMutation(ACTIONS_BASEURL, { + create: ActionApi.create, + createNested: ActionApi.createNested, + update: ActionApi.update, + delete: ActionApi.delete, + }); }; diff --git a/src/lib/api/agreements.ts b/src/lib/api/agreements.ts index 7953b841..9a63e1f8 100644 --- a/src/lib/api/agreements.ts +++ b/src/lib/api/agreements.ts @@ -2,11 +2,8 @@ import { siteConfig } from "@/site.config"; import { Id } from "@/types/general"; -import { - Agreement, - defaultAgreement, - transformAgreement, -} from "@/types/agreement"; +import { Agreement, defaultAgreement } from "@/types/agreement"; +import { transformEntityDates } from "@/types/general"; import { EntityApi } from "./entity-api"; const AGREEMENTS_BASEURL: string = `${siteConfig.env.backendServiceURL}/agreements`; @@ -95,7 +92,7 @@ export const useAgreementList = (coachingSessionId: Id) => { >( AGREEMENTS_BASEURL, () => AgreementApi.list(coachingSessionId), - transformAgreement, + transformEntityDates, coachingSessionId ); diff --git a/src/types/action.ts b/src/types/action.ts index fbb42c4d..a6806ee5 100644 --- a/src/types/action.ts +++ b/src/types/action.ts @@ -15,26 +15,6 @@ export interface Action { updated_at: DateTime; } -// The main purpose of having this parsing function is to be able to parse the -// returned DateTimeWithTimeZone (Rust type) string into something that ts-luxon -// will agree to work with internally. -export function parseAction(data: any): Action { - if (!isAction(data)) { - throw new Error("Invalid Action object data"); - } - return { - id: data.id, - coaching_session_id: data.coaching_session_id, - body: data.body, - user_id: data.user_id, - status: data.status, - status_changed_at: DateTime.fromISO(data.status_changed_at.toString()), - due_by: DateTime.fromISO(data.due_by.toString()), - created_at: DateTime.fromISO(data.created_at.toString()), - updated_at: DateTime.fromISO(data.updated_at.toString()), - }; -} - export function isAction(value: unknown): value is Action { if (!value || typeof value !== "object") { return false; diff --git a/src/types/general.ts b/src/types/general.ts index 85b93f11..9a12f0dd 100644 --- a/src/types/general.ts +++ b/src/types/general.ts @@ -49,3 +49,29 @@ export function getDateTimeFromString(dateTime: string): DateTime { const dt = dateTime.trim(); return dt.trim().length > 0 ? DateTime.fromISO(dt) : DateTime.now(); } + +// Type-safe transformation function with runtime validation that ensures +// that raw ISO date time stamps are transformed into valid ts-luxon DateTime +// instances. +export const transformEntityDates = (data: any): any => { + // Return early for non-objects + if (typeof data !== "object" || data === null) return data; + + // Create a new object with transformed dates + const transformed: Record = { ...data }; + + // Helper function for safe date conversion + const convertDate = (field: string) => { + if (typeof transformed[field] === "string") { + const dt = DateTime.fromISO(transformed[field]); + transformed[field] = dt.isValid ? dt : transformed[field]; + } + }; + + // Convert known date + time fields + convertDate("created_at"); + convertDate("updated_at"); + convertDate("due_by"); + + return transformed; +}; From 305ca0ec6dab39c7964c6a443fbc394e362f39af Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Mar 2025 18:40:30 -0500 Subject: [PATCH 12/21] Create an ORGANIZATIONS_BASEURL const like the other entities have and use it everywhere in the entity's Api --- src/lib/api/organizations.ts | 44 ++++++++++++++---------------------- 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/src/lib/api/organizations.ts b/src/lib/api/organizations.ts index c0f346f8..b775d67c 100644 --- a/src/lib/api/organizations.ts +++ b/src/lib/api/organizations.ts @@ -5,6 +5,8 @@ import { Id } from "@/types/general"; import { Organization, defaultOrganization } from "@/types/organization"; import { EntityApi } from "./entity-api"; +const ORGANIZATIONS_BASEURL: string = `${siteConfig.env.backendServiceURL}/organizations`; + /** * API client for organization-related operations. * @@ -19,12 +21,9 @@ export const OrganizationApi = { * @returns Promise resolving to an array of Organization objects */ list: async (userId: Id): Promise => - EntityApi.listFn( - `${siteConfig.env.backendServiceURL}/organizations`, - { - params: { user_id: userId }, - } - ), + EntityApi.listFn(ORGANIZATIONS_BASEURL, { + params: { user_id: userId }, + }), /** * Fetches a single organization by its ID. @@ -33,9 +32,7 @@ export const OrganizationApi = { * @returns Promise resolving to the Organization object */ get: async (id: Id): Promise => - EntityApi.getFn( - `${siteConfig.env.backendServiceURL}/organizations/${id}` - ), + EntityApi.getFn(`${ORGANIZATIONS_BASEURL}/${id}`), /** * Creates a new organization. @@ -45,7 +42,7 @@ export const OrganizationApi = { */ create: async (organization: Organization): Promise => EntityApi.createFn( - `${siteConfig.env.backendServiceURL}/organizations`, + ORGANIZATIONS_BASEURL, organization ), @@ -62,7 +59,7 @@ export const OrganizationApi = { */ update: async (id: Id, organization: Organization): Promise => EntityApi.updateFn( - `${siteConfig.env.backendServiceURL}/organizations/${id}`, + `${ORGANIZATIONS_BASEURL}/${id}`, organization ), @@ -73,9 +70,7 @@ export const OrganizationApi = { * @returns Promise resolving to the deleted Organization object */ delete: async (id: Id): Promise => - EntityApi.deleteFn( - `${siteConfig.env.backendServiceURL}/organizations/${id}` - ), + EntityApi.deleteFn(`${ORGANIZATIONS_BASEURL}/${id}`), }; /** @@ -95,7 +90,7 @@ export const OrganizationApi = { export const useOrganizationList = (userId: Id) => { const { entities, isLoading, isError, refresh } = EntityApi.useEntityList( - `${siteConfig.env.backendServiceURL}/organizations`, + ORGANIZATIONS_BASEURL, () => OrganizationApi.list(userId), userId ); @@ -122,9 +117,7 @@ export const useOrganizationList = (userId: Id) => { * * refresh: Function to manually trigger a refresh of the data */ export const useOrganization = (id: Id) => { - const url = id - ? `${siteConfig.env.backendServiceURL}/organizations/${id}` - : null; + const url = id ? `${ORGANIZATIONS_BASEURL}/${id}` : null; const fetcher = () => OrganizationApi.get(id); const { entity, isLoading, isError, refresh } = @@ -155,13 +148,10 @@ export const useOrganization = (id: Id) => { * Provides methods to create, update, and delete organizations. */ export const useOrganizationMutation = () => { - return EntityApi.useEntityMutation( - `${siteConfig.env.backendServiceURL}/organizations`, - { - create: OrganizationApi.create, - createNested: OrganizationApi.createNested, - update: OrganizationApi.update, - delete: OrganizationApi.delete, - } - ); + return EntityApi.useEntityMutation(ORGANIZATIONS_BASEURL, { + create: OrganizationApi.create, + createNested: OrganizationApi.createNested, + update: OrganizationApi.update, + delete: OrganizationApi.delete, + }); }; From ff19326165586a141b5f643a0a1848f51a08e49a Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Thu, 13 Mar 2025 19:49:23 -0500 Subject: [PATCH 13/21] Refactor coaching sessions to use the new API/Hook pattern. --- .../ui/coaching-session-selector.tsx | 11 +- src/components/ui/coaching-session.tsx | 2 +- .../ui/dashboard/coaching-session-list.tsx | 34 ++- src/lib/api/coaching-sessions.ts | 272 +++++++++++------- src/types/coaching-session.ts | 16 -- 5 files changed, 204 insertions(+), 131 deletions(-) diff --git a/src/components/ui/coaching-session-selector.tsx b/src/components/ui/coaching-session-selector.tsx index 0f4e690c..31583da8 100644 --- a/src/components/ui/coaching-session-selector.tsx +++ b/src/components/ui/coaching-session-selector.tsx @@ -11,7 +11,7 @@ import { SelectValue, } from "@/components/ui/select"; import { getDateTimeFromString, Id } from "@/types/general"; -import { useCoachingSessions } from "@/lib/api/coaching-sessions"; +import { useCoachingSessionList } from "@/lib/api/coaching-sessions"; import { useEffect, useState } from "react"; import { DateTime } from "ts-luxon"; import { useCoachingSessionStateStore } from "@/lib/providers/coaching-session-state-store-provider"; @@ -33,11 +33,18 @@ function CoachingSessionsSelectItems({ }: { relationshipId: Id; }) { + // TODO: for now we hardcode a 2 month window centered around now, + // eventually we want to make this be configurable somewhere + // (either on the page or elsewhere) + const fromDate = DateTime.now().minus({ month: 1 }); + const toDate = DateTime.now().plus({ month: 1 }); + const { coachingSessions, isLoading: isLoadingSessions, isError: isErrorSessions, - } = useCoachingSessions(relationshipId); + refresh, + } = useCoachingSessionList(relationshipId, fromDate, toDate); const { setCurrentCoachingSessions } = useCoachingSessionStateStore( (state) => state diff --git a/src/components/ui/coaching-session.tsx b/src/components/ui/coaching-session.tsx index 4c3ed7e3..d0e77880 100644 --- a/src/components/ui/coaching-session.tsx +++ b/src/components/ui/coaching-session.tsx @@ -74,4 +74,4 @@ const OverarchingGoal: React.FC = ({ return
{titleText}
; }; -export default CoachingSession; +export { CoachingSession }; diff --git a/src/components/ui/dashboard/coaching-session-list.tsx b/src/components/ui/dashboard/coaching-session-list.tsx index a1c1bee0..04dcee86 100644 --- a/src/components/ui/dashboard/coaching-session-list.tsx +++ b/src/components/ui/dashboard/coaching-session-list.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { DateTime } from "ts-luxon"; import { ArrowUpDown } from "lucide-react"; import { Dialog, @@ -13,27 +12,39 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Label } from "@/components/ui/label"; -import CoachingSession from "@/components/ui/coaching-session"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; import { useCoachingRelationshipStateStore } from "@/lib/providers/coaching-relationship-state-store-provider"; import { - createCoachingSession, - useCoachingSessions, + useCoachingSessionMutation, + useCoachingSessionList, } from "@/lib/api/coaching-sessions"; import { Calendar } from "@/components/ui/calendar"; import { getDateTimeFromString } from "@/types/general"; +import { + CoachingSession, + defaultCoachingSession, +} from "@/types/coaching-session"; +import { CoachingSession as CoachingSessionComponent } from "@/components/ui/coaching-session"; +import { DateTime } from "ts-luxon"; export default function CoachingSessionList() { const { currentCoachingRelationshipId } = useCoachingRelationshipStateStore( (state) => state ); const { isCoach } = useAuthStore((state) => state); + // TODO: for now we hardcode a 2 month window centered around now, + // eventually we want to make this be configurable somewhere + // (either on the page or elsewhere) + const fromDate = DateTime.now().minus({ month: 1 }); + const toDate = DateTime.now().plus({ month: 1 }); const { coachingSessions, isLoading: isLoadingCoachingSessions, isError: isErrorCoachingSessions, - mutate, - } = useCoachingSessions(currentCoachingRelationshipId); + refresh, + } = useCoachingSessionList(currentCoachingRelationshipId, fromDate, toDate); + + const { create: createCoachingSession } = useCoachingSessionMutation(); const [sortByDate, setSortByDate] = useState(true); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -53,14 +64,19 @@ export default function CoachingSessionList() { .set({ hour: hours, minute: minutes }) .toFormat("yyyy-MM-dd'T'HH:mm:ss"); - createCoachingSession(currentCoachingRelationshipId, dateTime) + const newCoachingSession: CoachingSession = { + ...defaultCoachingSession(), + coaching_relationship_id: currentCoachingRelationshipId, + date: dateTime, + }; + createCoachingSession(newCoachingSession) .then(() => { setIsDialogOpen(false); setNewSessionDate(undefined); setNewSessionTime(""); // Trigger a re-fetch of coaching sessions - mutate(); + refresh(); }) .catch((err) => { console.error("Failed to create new Coaching Session: " + err); @@ -156,7 +172,7 @@ export default function CoachingSessionList() { ) : (
{sortedSessions.map((coachingSession) => ( - diff --git a/src/lib/api/coaching-sessions.ts b/src/lib/api/coaching-sessions.ts index 6a819292..c41a9c82 100644 --- a/src/lib/api/coaching-sessions.ts +++ b/src/lib/api/coaching-sessions.ts @@ -1,128 +1,194 @@ // Interacts with the coaching_session endpoints import { siteConfig } from "@/site.config"; +import { Id } from "@/types/general"; import { CoachingSession, defaultCoachingSession, - isCoachingSession, - isCoachingSessionArray, - parseCoachingSession, - sortCoachingSessionArray, } from "@/types/coaching-session"; -import { Id, SortOrder } from "@/types/general"; -import axios, { AxiosError, AxiosResponse } from "axios"; -import useSWR from "swr"; +import { EntityApi } from "./entity-api"; import { DateTime } from "ts-luxon"; -// TODO: for now we hardcode a 2 month window centered around now, -// eventually we want to make this be configurable somewhere -// (either on the page or elsewhere) -const fromDate = DateTime.now().minus({ month: 1 }).toISODate(); -const toDate = DateTime.now().plus({ month: 1 }).toISODate(); +const COACHING_SESSIONS_BASEURL: string = `${siteConfig.env.backendServiceURL}/coaching_sessions`; -interface ApiResponse { - status_code: number; - data: CoachingSession[]; -} - -const fetcher = async ( - url: string, - relationshipId: Id -): Promise => - axios - .get(url, { +/** + * API client for coaching session-related operations. + * + * This object provides a collection of functions for interacting with the coaching session endpoints + * on the backend service. It handles the HTTP requests and response parsing for all CRUD operations. + */ +export const CoachingSessionApi = { + /* + * Fetches a list of coaching sessions associated with a specific coaching relationship. + * + * @param relationshipId The ID of the coaching relationship under which the list of + * coaching sessions should be retrieved from. + * @param fromDate A date specifying the earliest coaching session date to return. + * @param toDate A date specifying the latest coaching session date to match. + * @returns Promise resolving to an array of CoachingSession objects + */ + list: async ( + relationshipId: Id, + fromDate: DateTime, + toDate: DateTime + ): Promise => + EntityApi.listFn(COACHING_SESSIONS_BASEURL, { params: { coaching_relationship_id: relationshipId, - from_date: fromDate, - to_date: toDate, - }, - withCredentials: true, - timeout: 5000, - headers: { - "X-Version": siteConfig.env.backendApiVersion, + from_date: fromDate.toISODate(), + to_date: toDate.toISODate(), }, - }) - .then((res) => res.data.data); + }), -/// A hook to retrieve all CoachingSessions associated with relationshipId -export function useCoachingSessions(relationshipId: Id) { - console.debug(`relationshipId: ${relationshipId}`); - console.debug("fromDate: " + fromDate); - console.debug("toDate: " + toDate); + /** + * Fetches a single coaching session by its ID. + * + * @param id The ID of the coaching session to retrieve + * @returns Promise resolving to the CoachingSession object + */ + get: async (id: Id): Promise => + EntityApi.getFn(`${COACHING_SESSIONS_BASEURL}/${id}`), - const { data, error, isLoading, mutate } = useSWR( - relationshipId - ? [ - `${siteConfig.env.backendServiceURL}/coaching_sessions`, - relationshipId, - ] - : null, - ([url, _token]) => fetcher(url, relationshipId) - ); + /** + * Creates a new coaching session. + * + * @param coaching session The coaching session data to create + * @returns Promise resolving to the created CoachingSession object + */ + create: async (coachingSession: CoachingSession): Promise => + EntityApi.createFn( + COACHING_SESSIONS_BASEURL, + coachingSession + ), + + createNested: async ( + id: Id, + entity: CoachingSession + ): Promise => { + throw new Error("Create nested operation not implemented"); + }, - console.debug(`data: ${JSON.stringify(data)}`); + /** + * Updates an existing coaching session. + * + * @param id The ID of the coaching session to update + * @param coaching session The updated coaching session data + * @returns Promise resolving to the updated CoachingSession object + */ + update: async ( + id: Id, + coachingSession: CoachingSession + ): Promise => + EntityApi.updateFn( + `${COACHING_SESSIONS_BASEURL}/${id}`, + coachingSession + ), + + /** + * Deletes an coaching session. + * + * @param id The ID of the coaching session to delete + * @returns Promise resolving to the deleted CoachingSession object + */ + delete: async (id: Id): Promise => + EntityApi.deleteFn( + `${COACHING_SESSIONS_BASEURL}/${id}` + ), +}; + +/** + * A custom React hook that fetches a list of coaching sessions for a specific user. + * + * This hook uses SWR to efficiently fetch, cache, and revalidate coaching session data. + * It automatically refreshes data when the component mounts. + * + * @param relationshipId The ID of the coaching relationship under which the list of + * coaching sessions should be fetched from. + * @param fromDate A date specifying the earliest coaching session date to return. + * @param toDate A date specifying the latest coaching session date to match. + * @returns An object containing: + * + * * coachingSessions: Array of CoachingSession objects (empty array if data is not yet loaded) + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useCoachingSessionList = ( + relationshipId: Id, + fromDate: DateTime, + toDate: DateTime +) => { + const { entities, isLoading, isError, refresh } = + EntityApi.useEntityList( + COACHING_SESSIONS_BASEURL, + () => CoachingSessionApi.list(relationshipId, fromDate, toDate), + relationshipId + ); return { - coachingSessions: Array.isArray(data) ? data : [], + coachingSessions: entities, isLoading, - isError: error, - mutate, + isError, + refresh, }; -} - -export const createCoachingSession = async ( - coaching_relationship_id: Id, - date: string -): Promise => { - const axios = require("axios"); +}; - const newCoachingSessionJson = { - coaching_relationship_id: coaching_relationship_id, - date: date, - }; - console.debug( - "newCoachingSessiontJson: " + JSON.stringify(newCoachingSessionJson) - ); - // A full real note to be returned from the backend with the same body - var createdCoachingSession: CoachingSession = defaultCoachingSession(); - var err: string = ""; +/** + * A custom React hook that fetches a single coaching session by its ID. + * This hook uses SWR to efficiently fetch and cache coaching session data. + * It does not automatically revalidate the data on window focus, reconnect, or when data becomes stale. + * + * @param id The ID of the coaching session to fetch. If null or undefined, no fetch will occur. + * @returns An object containing: + * + * * coachingSession: The fetched CoachingSession object, or a default coaching session if not yet loaded + * * isLoading: Boolean indicating if the data is currently being fetched + * * isError: Error object if the fetch operation failed, undefined otherwise + * * refresh: Function to manually trigger a refresh of the data + */ +export const useCoachingSession = (id: Id) => { + const url = id ? `${COACHING_SESSIONS_BASEURL}/${id}` : null; + const fetcher = () => CoachingSessionApi.get(id); - const data = await axios - .post( - `${siteConfig.env.backendServiceURL}/coaching_sessions`, - newCoachingSessionJson, - { - withCredentials: true, - setTimeout: 5000, // 5 seconds before timing out trying to log in with the backend - headers: { - "X-Version": siteConfig.env.backendApiVersion, - "Content-Type": "application/json", - }, - } - ) - .then(function (response: AxiosResponse) { - // handle success - const coaching_session_data = response.data.data; - if (isCoachingSession(coaching_session_data)) { - createdCoachingSession = parseCoachingSession(coaching_session_data); - } - }) - .catch(function (error: AxiosError) { - // handle error - console.error(error.response?.status); - if (error.response?.status == 401) { - err = "Creation of Coaching Session failed: unauthorized."; - } else if (error.response?.status == 500) { - err = "Creation of Coaching Session failed: internal server error."; - } else { - err = `Creation of Coaching Session failed.`; - } - }); + const { entity, isLoading, isError, refresh } = + EntityApi.useEntity( + url, + fetcher, + defaultCoachingSession() + ); - if (err) { - console.error(err); - throw err; - } + return { + coachingSession: entity, + isLoading, + isError, + refresh, + }; +}; - return createdCoachingSession; +/** + * A custom React hook that provides mutation operations for coaching sessions with loading and error state management. + * This hook simplifies creating, updating, and deleting coaching sessions while handling loading states, + * error management, and cache invalidation automatically. + * + * @returns An object containing: + * create: Function to create a new coaching session + * update: Function to update an existing coaching session + * delete: Function to delete an coaching session + * isLoading: Boolean indicating if any operation is in progress + * error: Error object if the last operation failed, null otherwise + */ +/** + * Hook for coaching session mutations. + * Provides methods to create, update, and delete coaching sessions. + */ +export const useCoachingSessionMutation = () => { + return EntityApi.useEntityMutation( + COACHING_SESSIONS_BASEURL, + { + create: CoachingSessionApi.create, + createNested: CoachingSessionApi.createNested, + update: CoachingSessionApi.update, + delete: CoachingSessionApi.delete, + } + ); }; diff --git a/src/types/coaching-session.ts b/src/types/coaching-session.ts index 166df945..348f09ae 100644 --- a/src/types/coaching-session.ts +++ b/src/types/coaching-session.ts @@ -11,22 +11,6 @@ export interface CoachingSession { updated_at: DateTime; } -// The main purpose of having this parsing function is to be able to parse the -// returned DateTimeWithTimeZone (Rust type) string into something that ts-luxon -// will agree to work with internally. -export function parseCoachingSession(data: any): CoachingSession { - if (!isCoachingSession(data)) { - throw new Error("Invalid CoachingSession data"); - } - return { - id: data.id, - coaching_relationship_id: data.coaching_relationship_id, - date: data.date, - created_at: DateTime.fromISO(data.created_at.toString()), - updated_at: DateTime.fromISO(data.updated_at.toString()), - }; -} - export function isCoachingSession(value: unknown): value is CoachingSession { if (!value || typeof value !== "object") { return false; From dadf69bcd89f17c81874f0176a57736d87a037b2 Mon Sep 17 00:00:00 2001 From: Jim Hodapp Date: Sat, 15 Mar 2025 12:58:38 -0500 Subject: [PATCH 14/21] Refactor user session login/logout functions to use new hook/API pattern. --- src/components/ui/user-nav.tsx | 46 ++++++-------- src/components/user-auth-form.tsx | 19 +++++- src/lib/api/coaching-sessions.ts | 40 +++++++------ src/lib/api/entity-api.ts | 77 +++++++++++++++--------- src/lib/api/user-session.ts | 90 ---------------------------- src/lib/api/user-sessions.ts | 99 +++++++++++++++++++++++++++++++ src/types/user-session.ts | 17 +++--- 7 files changed, 216 insertions(+), 172 deletions(-) delete mode 100644 src/lib/api/user-session.ts create mode 100644 src/lib/api/user-sessions.ts diff --git a/src/components/ui/user-nav.tsx b/src/components/ui/user-nav.tsx index b32ef271..3a5f4e30 100644 --- a/src/components/ui/user-nav.tsx +++ b/src/components/ui/user-nav.tsx @@ -1,6 +1,6 @@ "use client"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Button } from "@/components/ui/button"; import { DropdownMenu, @@ -13,7 +13,7 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { logoutUser } from "@/lib/api/user-session"; +import { useUserSessionMutation } from "@/lib/api/user-sessions"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; import { useCoachingRelationshipStateStore } from "@/lib/providers/coaching-relationship-state-store-provider"; import { useCoachingSessionStateStore } from "@/lib/providers/coaching-session-state-store-provider"; @@ -23,13 +23,11 @@ import { useRouter } from "next/navigation"; export function UserNav() { const router = useRouter(); - const { logout } = useAuthStore((action) => action); - const { userSession } = useAuthStore((state) => ({ userSession: state.userSession, })); - + const { delete: deleteUserSession } = useUserSessionMutation(); const { resetOrganizationState } = useOrganizationStateStore( (action) => action ); @@ -41,24 +39,26 @@ export function UserNav() { ); async function logout_user() { - const err = await logoutUser(); - if (err.length > 0) { - console.error("Error while logging out: " + err); - } + try { + console.trace("Deleting active user session: ", userSession.id); + await deleteUserSession(userSession.id); - console.trace("Doing CoachingSessionStateStore property reset"); - resetCoachingSessionState(); + console.trace("Resetting CoachingSessionStateStore state"); + resetCoachingSessionState(); - console.trace("Doing CoachingRelationshipStateStore property reset"); - resetCoachingRelationshipState(); + console.trace("Resetting CoachingRelationshipStateStore state"); + resetCoachingRelationshipState(); - console.trace("Doing OrganizationStateStore property reset"); - resetOrganizationState(); + console.trace("Resetting OrganizationStateStore state"); + resetOrganizationState(); - console.trace("Doing AuthStore logout"); - logout(); + console.trace("Resetting AuthStore state"); + logout(); - router.push("/"); + router.push("/"); + } catch (err) { + console.error("Error while logging out session: ", userSession.id, err); + } } return ( @@ -66,7 +66,6 @@ export function UserNav() {