diff --git a/src/App.tsx b/src/App.tsx index 5baae9c..00bbecd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,6 +19,7 @@ import AdoptionTimelinePage from "./pages/AdoptionTimelinePage"; import ModalPreview from "./pages/ModalPreview"; import StatusPollingDemo from "./pages/StatusPollingDemo"; import CustodyTimelinePage from "./pages/CustodyTimelinePage"; +import CustodyListPage from "./pages/CustodyListPage"; import AdminApprovalQueuePage from "./pages/AdminApprovalQueuePage"; function App() { @@ -55,6 +56,7 @@ function App() { } /> {/* Custody Routes */} + } /> } diff --git a/src/api/custodyService.ts b/src/api/custodyService.ts index 74ef515..b6f674d 100644 --- a/src/api/custodyService.ts +++ b/src/api/custodyService.ts @@ -9,6 +9,16 @@ export interface CustodyTimelineEvent { } export const custodyService = { + async getList(params?: { status?: string[] }): Promise { + const searchParams = new URLSearchParams(); + if (params?.status?.length) { + searchParams.append('status', params.status.join(',')); + } + const query = searchParams.toString(); + const url = `/custody${query ? `?${query}` : ''}`; + return apiClient.get(url); + }, + async getDetails(custodyId: string): Promise { return apiClient.get(`/custody/${custodyId}`); }, diff --git a/src/components/ui/__tests__/CustodyStatusFilterChips.test.tsx b/src/components/ui/__tests__/CustodyStatusFilterChips.test.tsx new file mode 100644 index 0000000..ce6b9b3 --- /dev/null +++ b/src/components/ui/__tests__/CustodyStatusFilterChips.test.tsx @@ -0,0 +1,225 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import CustodyListPage from "../../../pages/CustodyListPage"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { custodyService } from "../../../api/custodyService"; + +// Mock the custodyService +vi.mock("../../../api/custodyService", () => ({ + custodyService: { + getList: vi.fn(), + }, +})); + +const mockCustodyService = custodyService as { + getList: vi.MockedFunction; +}; + +// Mock data +const mockCustodyList = [ + { + id: "1", + status: "ACTIVE", + petId: "pet-1", + custodianId: "user-1", + ownerId: "user-2", + startDate: "2024-01-01T00:00:00Z", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + { + id: "2", + status: "EXPIRING_SOON", + petId: "pet-2", + custodianId: "user-3", + ownerId: "user-4", + startDate: "2024-01-15T00:00:00Z", + endDate: "2024-02-15T00:00:00Z", + createdAt: "2024-01-15T00:00:00Z", + updatedAt: "2024-01-15T00:00:00Z", + }, +]; + +describe("Custody Status Filter Chips", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it("renders all custody status options", () => { + mockCustodyService.getList.mockResolvedValue([]); + + render(, { wrapper }); + + expect(screen.getByText("Pending")).toBeInTheDocument(); + expect(screen.getByText("Deposit Pending")).toBeInTheDocument(); + expect(screen.getByText("Deposit Confirmed")).toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + expect(screen.getByText("Expiring Soon")).toBeInTheDocument(); + expect(screen.getByText("Completing")).toBeInTheDocument(); + expect(screen.getByText("Completed")).toBeInTheDocument(); + expect(screen.getByText("Disputed")).toBeInTheDocument(); + expect(screen.getByText("Cancelled")).toBeInTheDocument(); + }); + + it("all chips start unselected", () => { + mockCustodyService.getList.mockResolvedValue([]); + + render(, { wrapper }); + + expect(screen.getByText("Active")).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByText("Expiring Soon")).toHaveAttribute("aria-pressed", "false"); + expect(screen.getByText("Completed")).toHaveAttribute("aria-pressed", "false"); + }); + + it("selects a chip and triggers API call with status filter", async () => { + mockCustodyService.getList + .mockResolvedValueOnce([]) // Initial call without filters + .mockResolvedValueOnce([mockCustodyList[0]]); // Call with ACTIVE filter + + render(, { wrapper }); + + // Click the "Active" chip + fireEvent.click(screen.getByText("Active")); + + // Wait for the API call with the filter + await waitFor(() => { + expect(mockCustodyService.getList).toHaveBeenCalledTimes(2); + }); + + // Verify the second call includes the status filter + expect(mockCustodyService.getList).toHaveBeenLastCalledWith({ + status: ["ACTIVE"], + }); + }); + + it("selects EXPIRING_SOON chip and shows amber highlight", () => { + mockCustodyService.getList.mockResolvedValue([]); + + render(, { wrapper }); + + const expiringSoonChip = screen.getByText("Expiring Soon"); + + // Initially unselected with amber border + expect(expiringSoonChip).toHaveAttribute("aria-pressed", "false"); + expect(expiringSoonChip).toHaveClass("border-amber-500", "text-amber-600"); + + // Click to select + fireEvent.click(expiringSoonChip); + + // Should now be selected with amber background + expect(expiringSoonChip).toHaveAttribute("aria-pressed", "true"); + expect(expiringSoonChip).toHaveClass("bg-amber-500", "text-white", "border-amber-600"); + }); + + it("deselects a chip on second click", async () => { + mockCustodyService.getList + .mockResolvedValueOnce([]) // Initial call + .mockResolvedValueOnce([mockCustodyList[0]]) // With ACTIVE filter + .mockResolvedValueOnce([]); // Without filter after deselect + + render(, { wrapper }); + + const activeChip = screen.getByText("Active"); + + // Select the chip + fireEvent.click(activeChip); + + await waitFor(() => { + expect(mockCustodyService.getList).toHaveBeenCalledWith({ + status: ["ACTIVE"], + }); + }); + + // Deselect the chip + fireEvent.click(activeChip); + + await waitFor(() => { + expect(mockCustodyService.getList).toHaveBeenLastCalledWith(undefined); + }); + + expect(activeChip).toHaveAttribute("aria-pressed", "false"); + }); + + it("can select multiple chips simultaneously", async () => { + mockCustodyService.getList + .mockResolvedValueOnce([]) // Initial call + .mockResolvedValueOnce([mockCustodyList[0]]) // With ACTIVE + .mockResolvedValueOnce(mockCustodyList); // With ACTIVE + EXPIRING_SOON + + render(, { wrapper }); + + // Select Active + fireEvent.click(screen.getByText("Active")); + + await waitFor(() => { + expect(mockCustodyService.getList).toHaveBeenCalledWith({ + status: ["ACTIVE"], + }); + }); + + // Select Expiring Soon + fireEvent.click(screen.getByText("Expiring Soon")); + + await waitFor(() => { + expect(mockCustodyService.getList).toHaveBeenLastCalledWith({ + status: ["ACTIVE", "EXPIRING_SOON"], + }); + }); + + expect(screen.getByText("Active")).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("Expiring Soon")).toHaveAttribute("aria-pressed", "true"); + }); + + it("shows 'Clear all' button when chips are selected", () => { + mockCustodyService.getList.mockResolvedValue([]); + + render(, { wrapper }); + + expect(screen.queryByText("Clear all")).not.toBeInTheDocument(); + + // Select a chip + fireEvent.click(screen.getByText("Active")); + + // Should show Clear all button + expect(screen.getByText("Clear all")).toBeInTheDocument(); + }); + + it("clears all selections when 'Clear all' is clicked", async () => { + mockCustodyService.getList + .mockResolvedValueOnce([]) // Initial call + .mockResolvedValueOnce([mockCustodyList[0]]) // With filter + .mockResolvedValueOnce([]); // After clear + + render(, { wrapper }); + + // Select a chip first + fireEvent.click(screen.getByText("Active")); + + await waitFor(() => { + expect(screen.getByText("Clear all")).toBeInTheDocument(); + }); + + // Click Clear all + fireEvent.click(screen.getByText("Clear all")); + + await waitFor(() => { + expect(mockCustodyService.getList).toHaveBeenLastCalledWith(undefined); + }); + + expect(screen.getByText("Active")).toHaveAttribute("aria-pressed", "false"); + expect(screen.queryByText("Clear all")).not.toBeInTheDocument(); + }); +}); diff --git a/src/hooks/__tests__/useCustodyList.test.tsx b/src/hooks/__tests__/useCustodyList.test.tsx new file mode 100644 index 0000000..680b175 --- /dev/null +++ b/src/hooks/__tests__/useCustodyList.test.tsx @@ -0,0 +1,145 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { useCustodyList } from "../useCustodyList"; +import { custodyService } from "../../api/custodyService"; + +// Mock the custodyService +vi.mock("../../api/custodyService", () => ({ + custodyService: { + getList: vi.fn(), + }, +})); + +const mockCustodyService = custodyService as { + getList: vi.MockedFunction; +}; + +// Mock data +const mockCustodyList = [ + { + id: "1", + status: "ACTIVE", + petId: "pet-1", + custodianId: "user-1", + ownerId: "user-2", + startDate: "2024-01-01T00:00:00Z", + createdAt: "2024-01-01T00:00:00Z", + updatedAt: "2024-01-01T00:00:00Z", + }, + { + id: "2", + status: "EXPIRING_SOON", + petId: "pet-2", + custodianId: "user-3", + ownerId: "user-4", + startDate: "2024-01-15T00:00:00Z", + endDate: "2024-02-15T00:00:00Z", + createdAt: "2024-01-15T00:00:00Z", + updatedAt: "2024-01-15T00:00:00Z", + }, +]; + +describe("useCustodyList", () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + vi.clearAllMocks(); + }); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + it("should fetch custody list without filters", async () => { + mockCustodyService.getList.mockResolvedValue(mockCustodyList); + + const { result } = renderHook(() => useCustodyList(), { wrapper }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockCustodyService.getList).toHaveBeenCalledWith(undefined); + expect(result.current.data).toEqual(mockCustodyList); + expect(result.current.isError).toBe(false); + }); + + it("should fetch custody list with status filter", async () => { + mockCustodyService.getList.mockResolvedValue([mockCustodyList[1]]); + + const { result } = renderHook(() => useCustodyList({ status: ["EXPIRING_SOON"] }), { + wrapper, + }); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockCustodyService.getList).toHaveBeenCalledWith({ + status: ["EXPIRING_SOON"], + }); + expect(result.current.data).toEqual([mockCustodyList[1]]); + }); + + it("should fetch custody list with multiple status filters", async () => { + mockCustodyService.getList.mockResolvedValue(mockCustodyList); + + const { result } = renderHook( + () => useCustodyList({ status: ["ACTIVE", "EXPIRING_SOON"] }), + { wrapper } + ); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(mockCustodyService.getList).toHaveBeenCalledWith({ + status: ["ACTIVE", "EXPIRING_SOON"], + }); + expect(result.current.data).toEqual(mockCustodyList); + }); + + it("should handle API errors", async () => { + const mockError = new Error("API Error"); + mockError.status = 500; + mockCustodyService.getList.mockRejectedValue(mockError); + + const { result } = renderHook(() => useCustodyList(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.isError).toBe(true); + expect(result.current.error).toEqual(mockError); + expect(result.current.data).toBeUndefined(); + }); + + it("should have correct query key", async () => { + mockCustodyService.getList.mockResolvedValue(mockCustodyList); + + renderHook(() => useCustodyList({ status: ["ACTIVE"] }), { wrapper }); + + await waitFor(() => { + expect(mockCustodyService.getList).toHaveBeenCalled(); + }); + + // Verify the query was cached with the correct key + const cachedData = queryClient.getQueryData(["custody-list", ["ACTIVE"]]); + expect(cachedData).toEqual(mockCustodyList); + }); +}); diff --git a/src/hooks/useCustodyList.ts b/src/hooks/useCustodyList.ts new file mode 100644 index 0000000..495fe99 --- /dev/null +++ b/src/hooks/useCustodyList.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { custodyService } from "../api/custodyService"; +import { useApiQuery } from "./useApiQuery"; + +interface UseCustodyListParams { + status?: string[]; +} + +export function useCustodyList(params?: UseCustodyListParams) { + return useApiQuery( + ['custody-list', params?.status], + () => custodyService.getList(params), + { + enabled: true, + } + ); +} diff --git a/src/pages/CustodyListPage.tsx b/src/pages/CustodyListPage.tsx new file mode 100644 index 0000000..39cb797 --- /dev/null +++ b/src/pages/CustodyListPage.tsx @@ -0,0 +1,226 @@ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { StatusFilterChips, type Option } from "../components/ui/StatusFilterChips"; +import { useCustodyList } from "../hooks/useCustodyList"; +import { CustodyStatusBadge } from "../components/custody/CustodyStatusBadge"; +import type { CustodyStatus } from "../types/adoption"; +import { Skeleton } from "../components/ui/Skeleton"; + +// Define custody status options for filter +const CUSTODY_STATUS_OPTIONS: Option[] = [ + { value: "PENDING", label: "Pending" }, + { value: "DEPOSIT_PENDING", label: "Deposit Pending" }, + { value: "DEPOSIT_CONFIRMED", label: "Deposit Confirmed" }, + { value: "ACTIVE", label: "Active" }, + { value: "EXPIRING_SOON", label: "Expiring Soon" }, + { value: "COMPLETING", label: "Completing" }, + { value: "COMPLETED", label: "Completed" }, + { value: "DISPUTED", label: "Disputed" }, + { value: "CANCELLED", label: "Cancelled" }, +]; + +// Special styling for EXPIRING_SOON to show amber highlight +const getFilterChipClassName = (value: string, isSelected: boolean) => { + const baseClasses = "px-3 py-1 rounded-full text-sm transition"; + + if (isSelected) { + if (value === "EXPIRING_SOON") { + return `${baseClasses} bg-amber-500 text-white border-2 border-amber-600`; + } + return `${baseClasses} bg-[#E84D2A] text-white`; + } + + if (value === "EXPIRING_SOON") { + return `${baseClasses} border-2 border-amber-500 text-amber-600 bg-white hover:bg-amber-50`; + } + + return `${baseClasses} border border-[#E84D2A] text-[#E84D2A] bg-white hover:bg-[#FFF2E5]`; +}; + +export default function CustodyListPage() { + const [selectedStatuses, setSelectedStatuses] = useState([]); + + const { data: custodyList, isLoading, isError } = useCustodyList({ + status: selectedStatuses.length > 0 ? selectedStatuses : undefined, + }); + + const handleStatusChange = (statuses: string[]) => { + setSelectedStatuses(statuses); + }; + + return ( +
+
+ {/* Page Header */} +
+

+ Custody +

+

Custody List

+

+ {custodyList?.length || 0} custody arrangements +

+
+ + {/* Filters Section */} +
+

+ Filter by Status +

+ +
+ + {/* Custody List */} +
+

+ Custody Arrangements +

+ + {isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ + +
+ ))} +
+ ) : isError ? ( +
+ Unable to load custody list. +
+ ) : !custodyList || custodyList.length === 0 ? ( +
+ {selectedStatuses.length > 0 + ? "No custody arrangements found with the selected filters." + : "No custody arrangements found."} +
+ ) : ( +
+ {custodyList.map((custody) => ( +
+
+
+
+

+ Custody #{custody.id} +

+ +
+ +
+
+ Pet ID: {custody.petId} +
+
+ Custodian: {custody.custodianId} +
+
+ Owner: {custody.ownerId} +
+
+ Start Date:{" "} + {new Date(custody.startDate).toLocaleDateString()} +
+ {custody.endDate && ( +
+ End Date:{" "} + {new Date(custody.endDate).toLocaleDateString()} +
+ )} +
+
+ +
+ + View Timeline + +
+
+
+ ))} +
+ )} +
+
+
+ ); +} + +// Custom StatusFilterChips component with special styling for EXPIRING_SOON +interface CustomStatusFilterChipsProps { + options: Option[]; + value?: string[]; + onChange?: (value: string[]) => void; +} + +function CustomStatusFilterChips({ + options, + value = [], + onChange, +}: CustomStatusFilterChipsProps) { + const toggle = (val: string) => { + if (value.includes(val)) { + onChange?.(value.filter((v) => v !== val)); + } else { + onChange?.([...value, val]); + } + }; + + const clearAll = () => onChange?.([]); + + return ( +
+
+ {options.map((opt) => { + const isSelected = value.includes(opt.value); + + return ( + + ); + })} +
+ + {value.length > 0 && ( + + )} +
+ ); +}