diff --git a/package-lock.json b/package-lock.json index d7d260c..ea33398 100644 --- a/package-lock.json +++ b/package-lock.json @@ -38,6 +38,7 @@ "react": "^18", "react-day-picker": "^8.10.0", "react-dom": "^18", + "swr": "^2.2.5", "tailwind-merge": "^2.2.0", "tailwindcss-animate": "^1.0.7", "ts-luxon": "^4.5.2", @@ -6035,6 +6036,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.5.tgz", + "integrity": "sha512-QtxqyclFeAsxEUeZIYmsaQ0UjimSq1RZ9Un7I68/0ClKK/U3LoyQunwkQfJZr2fc22DfIXLNDc2wFyTEikCUpg==", + "license": "MIT", + "dependencies": { + "client-only": "^0.0.1", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/tailwind-merge": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.3.0.tgz", diff --git a/package.json b/package.json index c539ea3..133720e 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "react": "^18", "react-day-picker": "^8.10.0", "react-dom": "^18", + "swr": "^2.2.5", "tailwind-merge": "^2.2.0", "tailwindcss-animate": "^1.0.7", "ts-luxon": "^4.5.2", diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 00ab6c4..3062dc3 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -8,7 +8,7 @@ import Image from "next/image"; import { cn } from "@/lib/utils"; -import { SelectCoachingSession } from "@/components/ui/dashboard/select-coaching-session"; +import { JoinCoachingSession } from "@/components/ui/dashboard/join-coaching-session"; import { useAuthStore } from "@/lib/providers/auth-store-provider"; // export const metadata: Metadata = { @@ -54,7 +54,7 @@ export default function DashboardPage() {
- +
diff --git a/src/components/ui/dashboard/dynamic-api-select.tsx b/src/components/ui/dashboard/dynamic-api-select.tsx new file mode 100644 index 0000000..d99480d --- /dev/null +++ b/src/components/ui/dashboard/dynamic-api-select.tsx @@ -0,0 +1,150 @@ +import React, { useState } from "react"; +import { useApiData } from "@/hooks/use-api-data"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + CoachingSession, + isCoachingSession, + sortCoachingSessionArray, +} from "@/types/coaching-session"; +import { DateTime } from "ts-luxon"; +import { SortOrder } from "@/types/general"; + +interface DynamicApiSelectProps { + url: string; + method?: "GET" | "POST"; + params?: Record; + onChange: (value: string) => void; + placeholder?: string; + getOptionLabel: (item: T) => string; + getOptionValue: (item: T) => string; // TODO: return generic type + elementId: string; + groupByDate?: boolean; +} + +interface ApiResponse { + status_code: number; + data: T[]; +} + +export function DynamicApiSelect({ + url, + method = "GET", + params = {}, + onChange, + placeholder = "Select an option", + getOptionLabel, + getOptionValue, + elementId, + groupByDate = false, +}: DynamicApiSelectProps) { + const { + data: response, + isLoading, + error, + } = useApiData>(url, { method, params }); + const [value, setValue] = useState(""); // use AppStateStore + + const handleValueChange = (newValue: string) => { // TODO: update state store + setValue(newValue); + onChange(newValue); + }; + + if (isLoading) return

Loading...

; + if (error) return

Error: {error.message}

; + if (!response || response.status_code !== 200) + return

Error: Invalid response

; + + const items = response.data; + + const renderSessions = ( + sessions: CoachingSession[], + label: string, + filterFn: (session: CoachingSession) => boolean, + sortOrder: SortOrder + ) => { + const filteredSessions = sessions.filter(filterFn); + const sortedSessions = sortCoachingSessionArray( + filteredSessions, + sortOrder + ); + + return ( + sortedSessions.length > 0 && ( + + {label} + {sortedSessions.map((session) => ( + + {DateTime.fromISO(session.date).toLocaleString( + DateTime.DATETIME_FULL + )} + + ))} + + ) + ); + }; + + const renderCoachingSessions = (sessions: CoachingSession[]) => ( + + {sessions.length === 0 ? ( + + None found + + ) : ( + <> + {renderSessions( + sessions, + "Previous Sessions", + (session) => DateTime.fromISO(session.date) < DateTime.now(), + SortOrder.Descending + )} + {renderSessions( + sessions, + "Upcoming Sessions", + (session) => DateTime.fromISO(session.date) >= DateTime.now(), + SortOrder.Ascending + )} + + )} + + ); + + const renderOtherItems = (items: T[]) => ( + + {items.length === 0 ? ( + + None found + + ) : ( + items.map((item, index) => ( + + {getOptionLabel(item)} + + )) + )} + + ); + + const coachingSessions = groupByDate + ? (items.filter(isCoachingSession) as CoachingSession[]) + : []; + + return ( + + ); +} diff --git a/src/components/ui/dashboard/join-coaching-session.tsx b/src/components/ui/dashboard/join-coaching-session.tsx new file mode 100644 index 0000000..ac164ae --- /dev/null +++ b/src/components/ui/dashboard/join-coaching-session.tsx @@ -0,0 +1,133 @@ +import React from "react"; +import { useAppStateStore } from "@/lib/providers/app-state-store-provider"; +import { Id } from "@/types/general"; +import { DynamicApiSelect } from "./dynamic-api-select"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Organization } from "@/types/organization"; +import { CoachingRelationshipWithUserNames } from "@/types/coaching_relationship_with_user_names"; +import { + CoachingSession, +} from "@/types/coaching-session"; +import { DateTime } from "ts-luxon"; +import { Label } from "@/components/ui/label"; +import { Button } from "../button"; +import Link from "next/link"; + +export interface CoachingSessionCardProps { + userId: Id; +} + +export function JoinCoachingSession({ + userId: userId, +}: CoachingSessionCardProps) { + + const FROM_DATE = DateTime.now().minus({ month: 1 }).toISODate(); + const TO_DATE = DateTime.now().plus({ month: 1 }).toISODate(); + + const { + organization, + organizationId, + relationship, + relationshipId, + coachingSession, + coachingSessionId, + setOrganizationId, + setRelationshipId, + setCoachingSessionId + } = useAppStateStore(state => ({ + organization: state.setOrganization, + organizationId: state.organizationId, + relationship: state.setCoachingRelationship, + relationshipId: state.relationshipId, + coachingSession: state.setCoachingSession, + coachingSessionId: state.coachingSessionId, + setOrganizationId: state.setOrganizationId, + setRelationshipId: state.setRelationshipId, + setCoachingSessionId: state.setCoachingSessionId + })); + + // TODO: pass Organization type + const handleOrganizationSelection = (value: string) => { + setOrganizationId(value); + } + + // TODO: pass Relationship type + const handleRelationshipSelection = (value: string) => { + setRelationshipId(value); + setCoachingSessionId; + } + + // TODO: pass CoachingSession type + const handleSessionSelection = (value: string) => { + setCoachingSessionId(value); + } + + return ( + + + Join a Coaching Session + + +
+ + + + url="/organizations" + params={{ userId }} + onChange={handleOrganizationSelection} + placeholder="Select an organization" + getOptionLabel={(org) => org.name} + getOptionValue={(org) => org.id} + elementId="organization-selector" + /> +
+ {organizationId.length > 0 && ( +
+ + + + url={`/organizations/${organizationId}/coaching_relationships`} + params={{ organizationId: organizationId }} + onChange={handleRelationshipSelection} + placeholder="Select coaching relationship" + getOptionLabel={(relationship) => + `${relationship.coach_first_name} ${relationship.coach_last_name} -> ${relationship.coachee_first_name} ${relationship.coach_last_name}` + } + getOptionValue={(relationship) => relationship.id} + elementId="relationship-selector" + /> +
+ )} + {relationshipId.length > 0 && ( +
+ + + + url="/coaching_sessions" + params={{ + coaching_relationship_id: relationshipId, + from_date: FROM_DATE, + to_Date: TO_DATE, + }} + onChange={handleSessionSelection} + placeholder="Select coaching session" + getOptionLabel={(session) => session.date} + getOptionValue={(session) => session.id} + elementId="session-selector" + groupByDate={true} + /> +
+ )} + {coachingSessionId.length > 0 && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/components/ui/dashboard/select-coaching-session.tsx b/src/components/ui/dashboard/select-coaching-session.tsx deleted file mode 100644 index f22345d..0000000 --- a/src/components/ui/dashboard/select-coaching-session.tsx +++ /dev/null @@ -1,301 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { - Select, - SelectContent, - SelectGroup, - SelectItem, - SelectLabel, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { fetchCoachingRelationshipsWithUserNames } from "@/lib/api/coaching-relationships"; -import { fetchCoachingSessions } from "@/lib/api/coaching-sessions"; -import { fetchOrganizationsByUserId } from "@/lib/api/organizations"; -import { useAppStateStore } from "@/lib/providers/app-state-store-provider"; -import { - CoachingSession, - coachingSessionToString, - getCoachingSessionById, -} from "@/types/coaching-session"; -import { - CoachingRelationshipWithUserNames, - coachingRelationshipWithUserNamesToString, - getCoachingRelationshipById, -} from "@/types/coaching_relationship_with_user_names"; -import { getDateTimeFromString, Id } from "@/types/general"; -import { - getOrganizationById, - Organization, - organizationToString, -} from "@/types/organization"; -import Link from "next/link"; -import { useEffect, useState } from "react"; -import { DateTime } from "ts-luxon"; - -export interface CoachingSessionProps { - /** The current logged in user's Id */ - userId: Id; -} - -export function SelectCoachingSession({ - userId: userId, - ...props -}: CoachingSessionProps) { - const { organizationId, setOrganizationId } = useAppStateStore( - (state) => state - ); - const { organization, setOrganization } = useAppStateStore((state) => state); - const { relationshipId, setRelationshipId } = useAppStateStore( - (state) => state - ); - const { coachingRelationship, setCoachingRelationship } = useAppStateStore( - (state) => state - ); - const { coachingSessionId, setCoachingSessionId } = useAppStateStore( - (state) => state - ); - const { coachingSession, setCoachingSession } = useAppStateStore( - (state) => state - ); - - const [organizations, setOrganizations] = useState([]); - const [coachingRelationships, setCoachingRelationships] = useState< - CoachingRelationshipWithUserNames[] - >([]); - const [coachingSessions, setCoachingSessions] = useState( - [] - ); - - useEffect(() => { - async function loadOrganizations() { - if (!userId) return; - - await fetchOrganizationsByUserId(userId) - .then(([orgs]) => { - // Apparently it's normal for this to be triggered twice in modern - // React versions in strict + development modes - // https://stackoverflow.com/questions/60618844/react-hooks-useeffect-is-called-twice-even-if-an-empty-array-is-used-as-an-ar - console.debug("setOrganizations: " + JSON.stringify(orgs)); - setOrganizations(orgs); - }) - .catch(([err]) => { - console.error("Failed to fetch Organizations: " + err); - }); - } - loadOrganizations(); - }, [userId]); - - useEffect(() => { - async function loadCoachingRelationships() { - if (!organizationId) return; - - console.debug("organizationId: " + organizationId); - - await fetchCoachingRelationshipsWithUserNames(organizationId) - .then(([relationships]) => { - console.debug( - "setCoachingRelationships: " + JSON.stringify(relationships) - ); - setCoachingRelationships(relationships); - }) - .catch(([err]) => { - console.error("Failed to fetch coaching relationships: " + err); - }); - } - loadCoachingRelationships(); - }, [organizationId]); - - useEffect(() => { - async function loadCoachingSessions() { - if (!organizationId) return; - - await fetchCoachingSessions(relationshipId) - .then(([coaching_sessions]) => { - console.debug( - "setCoachingSessions: " + JSON.stringify(coaching_sessions) - ); - setCoachingSessions(coaching_sessions); - }) - .catch(([err]) => { - console.error("Failed to fetch coaching sessions: " + err); - }); - } - loadCoachingSessions(); - }, [relationshipId]); - - const handleSetOrganization = (organizationId: Id) => { - setOrganizationId(organizationId); - const organization = getOrganizationById(organizationId, organizations); - console.debug("organization: " + organizationToString(organization)); - setOrganization(organization); - }; - - const handleSetCoachingRelationship = (coachingRelationshipId: string) => { - setRelationshipId(coachingRelationshipId); - const coachingRelationship = getCoachingRelationshipById( - coachingRelationshipId, - coachingRelationships - ); - console.debug( - "coachingRelationship: " + - coachingRelationshipWithUserNamesToString(coachingRelationship) - ); - setCoachingRelationship(coachingRelationship); - }; - - const handleSetCoachingSession = (coachingSessionId: string) => { - setCoachingSessionId(coachingSessionId); - const coachingSession = getCoachingSessionById( - coachingSessionId, - coachingSessions - ); - console.debug( - "coachingSession: " + coachingSessionToString(coachingSession) - ); - setCoachingSession(coachingSession); - }; - - return ( - - - Join a Coaching Session - - Select current organization, relationship and session - - - -
- - -
-
- - -
-
- - -
-
- - - -
- ); -} diff --git a/src/hooks/use-api-data.ts b/src/hooks/use-api-data.ts new file mode 100644 index 0000000..fdd4298 --- /dev/null +++ b/src/hooks/use-api-data.ts @@ -0,0 +1,60 @@ +import useSWR from 'swr'; +import { siteConfig } from '@/site.config'; + +interface FetcherOptions { + url: string, + method?: 'GET' | 'POST', + params?: Record, + // body?: Record +} + +const baseUrl = siteConfig.url; + +const fetcher = async ({ url, method = 'POST', params }: FetcherOptions) => { + const fullUrl = `${baseUrl}${url}`; + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + 'X-Version': '0.0.1' + }; + + const fetchOptions: RequestInit = { + method, + headers, + credentials: 'include', + }; + + const response = await fetch(fullUrl, fetchOptions); + if (!response.ok) { + const errorData = await response.json().catch(() => null); + throw new Error(errorData?.message || 'An error occurred while fetching the data.'); + } + return response.json(); +}; + +export function useApiData( + url: string, + options: { + method?: 'GET' | 'POST' + params?: Record + body?: Record + } = {} +) { + const { method = 'POST', params = {}, body = {} } = options + + const { data, error, isLoading, mutate } = useSWR( + { url, method, params, body }, + fetcher, + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ) + + return { + data, + isLoading, + error, + mutate, + } +} \ No newline at end of file diff --git a/src/lib/api/coaching-sessions.ts b/src/lib/api/coaching-sessions.ts index 6d68490..a76647d 100644 --- a/src/lib/api/coaching-sessions.ts +++ b/src/lib/api/coaching-sessions.ts @@ -12,7 +12,7 @@ import { DateTime } from "ts-luxon"; export const fetchCoachingSessions = async ( coachingRelationshipId: Id -): Promise<[CoachingSession[], string]> => { +): Promise => { const axios = require("axios"); var coaching_sessions: CoachingSession[] = []; @@ -59,15 +59,9 @@ export const fetchCoachingSessions = async ( // handle error console.error(error.response?.status); if (error.response?.status == 401) { - console.error("Retrieval of CoachingSessions failed: unauthorized."); err = "Retrieval of CoachingSessions failed: unauthorized."; } else { console.log(error); - console.error( - `Retrieval of CoachingSessions by coaching relationship Id (` + - coachingRelationshipId + - `) failed.` - ); err = `Retrieval of CoachingSessions by coaching relationship Id (` + coachingRelationshipId + @@ -75,5 +69,7 @@ export const fetchCoachingSessions = async ( } }); - return [coaching_sessions, err]; + if (err) throw err; + + return coaching_sessions; }; diff --git a/src/lib/api/organizations.ts b/src/lib/api/organizations.ts index 3cade9c..724ef9a 100644 --- a/src/lib/api/organizations.ts +++ b/src/lib/api/organizations.ts @@ -55,9 +55,7 @@ export const fetchOrganizations = async (): Promise< return [organizations, err]; }; -export const fetchOrganization = async ( - id: Id -): Promise<[Organization, string]> => { +export const fetchOrganization = async (id: Id): Promise => { const axios = require("axios"); var organization: Organization = defaultOrganization(); @@ -81,16 +79,15 @@ export const fetchOrganization = async ( // handle error console.error(error.response?.status); if (error.response?.status == 401) { - console.error("Retrieval of Organization failed: unauthorized."); err = "Retrieval of Organization failed: unauthorized."; } else { - console.log(error); - console.error(`Retrieval of Organization(` + id + `) failed.`); err = `Retrieval of Organization(` + id + `) failed.`; } }); - return [organization, err]; + if (err) throw err; + + return organization; }; export const fetchOrganizationsByUserId = async ( diff --git a/src/lib/stores/app-state-store.ts b/src/lib/stores/app-state-store.ts index 8ed892e..f1982a8 100644 --- a/src/lib/stores/app-state-store.ts +++ b/src/lib/stores/app-state-store.ts @@ -20,7 +20,7 @@ interface AppState { coachingRelationship: CoachingRelationshipWithUserNames; } -interface AppStateActions { +export interface AppStateActions { setOrganizationId: (organizationId: Id) => void; setRelationshipId: (relationshipId: Id) => void; setCoachingSessionId: (coachingSessionId: Id) => void; diff --git a/src/site.config.ts b/src/site.config.ts index 79d560e..4465a72 100644 --- a/src/site.config.ts +++ b/src/site.config.ts @@ -1,6 +1,6 @@ export const siteConfig = { name: "Refactor Coaching & Mentoring", - url: "https://refactorcoach.com", + url: "http://localhost:4000", ogImage: "https://ui.shadcn.com/og.jpg", locale: "us", titleStyle: SessionTitleStyle.CoachFirstCoacheeFirstDate,