From 16cc052630c0fd9875ef5b6d7c2be6ef2cbea050 Mon Sep 17 00:00:00 2001 From: Clinton6801 Date: Thu, 28 May 2026 23:30:11 +0100 Subject: [PATCH 1/5] feat(#191): add RaiseDisputeModal with form, submission, XHR progress and tests --- package-lock.json | 36 ++-- .../disputes/RaiseDisputeTrigger.tsx | 44 ++++ .../__tests__/RaiseDisputeTrigger.test.tsx | 84 ++++++++ src/components/modals/RaiseDisputeModal.tsx | 192 +++++++++++++++++ .../__tests__/RaiseDisputeModal.test.tsx | 199 ++++++++++++++++++ src/hooks/useMutateRaiseDispute.ts | 149 +++++++++++++ src/mocks/handlers/dispute.ts | 111 +++++----- 7 files changed, 743 insertions(+), 72 deletions(-) create mode 100644 src/components/disputes/RaiseDisputeTrigger.tsx create mode 100644 src/components/disputes/__tests__/RaiseDisputeTrigger.test.tsx create mode 100644 src/components/modals/RaiseDisputeModal.tsx create mode 100644 src/components/modals/__tests__/RaiseDisputeModal.test.tsx create mode 100644 src/hooks/useMutateRaiseDispute.ts diff --git a/package-lock.json b/package-lock.json index 887092a..b8bb4f4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,11 +18,13 @@ "react-dom": "^19.2.4", "react-hot-toast": "^2.6.0", "react-router-dom": "^7.13.1", - "tailwindcss": "^4.2.0" + "tailwindcss": "^4.2.0", + "undici": "^8.1.0" }, "devDependencies": { "@eslint/js": "^9.39.1", "@playwright/test": "^1.59.1", + "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -2119,7 +2121,6 @@ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -2208,8 +2209,7 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3315,8 +3315,7 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/electron-to-chromium": { "version": "1.5.344", @@ -4284,6 +4283,16 @@ "node": "20 || >=22" } }, + "node_modules/jsdom/node_modules/undici": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.26.0.tgz", + "integrity": "sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -4664,7 +4673,6 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5033,7 +5041,6 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5049,7 +5056,6 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -5140,8 +5146,7 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-refresh": { "version": "0.18.0", @@ -5726,13 +5731,12 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", - "dev": true, + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.3.0.tgz", + "integrity": "sha512-TkUDgb6tl7KOGZ+7e8E3d2FYgUQgF6z5YypqjWmixVQSQERFcVrVg0ySADm2LVLRh5ljAaHTCR5Fmz3Q34rB7Q==", "license": "MIT", "engines": { - "node": ">=20.18.1" + "node": ">=22.19.0" } }, "node_modules/undici-types": { diff --git a/src/components/disputes/RaiseDisputeTrigger.tsx b/src/components/disputes/RaiseDisputeTrigger.tsx new file mode 100644 index 0000000..5e5fbf6 --- /dev/null +++ b/src/components/disputes/RaiseDisputeTrigger.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { RaiseDisputeModal } from "../modals/RaiseDisputeModal"; + +interface Props { + adoptionId: string; + adoptionStatus: string; + raisedBy: string; + isAdopter: boolean; + isShelter: boolean; +} + +export function RaiseDisputeTrigger({ + adoptionId, + adoptionStatus, + raisedBy, + isAdopter, + isShelter, +}: Props) { + const [isModalOpen, setIsModalOpen] = useState(false); + + const canRaiseDispute = + adoptionStatus === "CUSTODY_ACTIVE" && (isAdopter || isShelter); + + if (!canRaiseDispute) return null; + + return ( + <> + + + setIsModalOpen(false)} + adoptionId={adoptionId} + raisedBy={raisedBy} + /> + + ); +} \ No newline at end of file diff --git a/src/components/disputes/__tests__/RaiseDisputeTrigger.test.tsx b/src/components/disputes/__tests__/RaiseDisputeTrigger.test.tsx new file mode 100644 index 0000000..98b6012 --- /dev/null +++ b/src/components/disputes/__tests__/RaiseDisputeTrigger.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, fireEvent } from "@testing-library/react"; +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { RaiseDisputeTrigger } from "../RaiseDisputeTrigger"; +import * as disputeHookModule from "../../../hooks/useMutateRaiseDispute"; + +vi.mock("../../../hooks/useMutateRaiseDispute", () => ({ + useMutateRaiseDispute: vi.fn(), +})); + +vi.mock("react-hot-toast", () => ({ + default: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + }), +})); + +const mockHook = () => { + vi.mocked(disputeHookModule.useMutateRaiseDispute).mockReturnValue({ + mutateAsync: vi.fn().mockResolvedValue({}), + isPending: false, + error: null, + } as any); +}; + +const renderTrigger = (props?: any) => { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + return render( + + + , + ); +}; + +describe("RaiseDisputeTrigger", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockHook(); + }); + + it("renders the trigger button when status is CUSTODY_ACTIVE and isAdopter", () => { + renderTrigger(); + expect(screen.getByRole("button", { name: /raise a dispute/i })).toBeInTheDocument(); + }); + + it("renders the trigger button when status is CUSTODY_ACTIVE and isShelter", () => { + renderTrigger({ isAdopter: false, isShelter: true }); + expect(screen.getByRole("button", { name: /raise a dispute/i })).toBeInTheDocument(); + }); + + it("does not render when status is not CUSTODY_ACTIVE", () => { + renderTrigger({ adoptionStatus: "PENDING" }); + expect(screen.queryByRole("button", { name: /raise a dispute/i })).not.toBeInTheDocument(); + }); + + it("does not render when neither isAdopter nor isShelter", () => { + renderTrigger({ isAdopter: false, isShelter: false }); + expect(screen.queryByRole("button", { name: /raise a dispute/i })).not.toBeInTheDocument(); + }); + + it("opens the modal when trigger button is clicked", () => { + renderTrigger(); + fireEvent.click(screen.getByRole("button", { name: /raise a dispute/i })); + expect(screen.getByText("Tell us why you're raising this dispute.")).toBeInTheDocument(); + }); + + it("closes the modal when Escape is pressed", () => { + renderTrigger(); + fireEvent.click(screen.getByRole("button", { name: /raise a dispute/i })); + expect(screen.getByText("Tell us why you're raising this dispute.")).toBeInTheDocument(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByText("Tell us why you're raising this dispute.")).not.toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/src/components/modals/RaiseDisputeModal.tsx b/src/components/modals/RaiseDisputeModal.tsx new file mode 100644 index 0000000..ee1d1eb --- /dev/null +++ b/src/components/modals/RaiseDisputeModal.tsx @@ -0,0 +1,192 @@ +import { useState, useEffect, useCallback } from "react"; +import { FileUploadZone } from "../ui/FileUploadZone"; +import { useMutateRaiseDispute } from "../../hooks/useMutateRaiseDispute"; +import toast from "react-hot-toast"; + +interface Props { + isOpen: boolean; + onClose: () => void; + adoptionId: string; + raisedBy: string; +} + +const MIN_LEN = 30; +const MAX_FILES = 5; + +export function RaiseDisputeModal({ isOpen, onClose, adoptionId, raisedBy }: Props) { + const [reason, setReason] = useState(""); + const [files, setFiles] = useState>([null]); + const [fileProgress, setFileProgress] = useState([]); + const [inlineError, setInlineError] = useState(null); + + const handleFileProgress = (index: number, pct: number) => { + setFileProgress((prev) => { + const next = [...prev]; + next[index] = pct; + return next; + }); + }; + + const { mutateAsync, isPending, error } = useMutateRaiseDispute({ + onProgress: handleFileProgress, + }); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === "Escape" && !isPending) onClose(); + }, + [isPending, onClose], + ); + + useEffect(() => { + if (!isOpen) return; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, handleKeyDown]); + + if (!isOpen) return null; + + const updateFile = (index: number, f: File | null) => { + const next = [...files]; + next[index] = f; + setFiles(next); + }; + + const addFileSlot = () => { + if (files.length >= MAX_FILES) return; + setFiles((s) => [...s, null]); + }; + + const handleBackdropClick = () => { + if (!isPending) onClose(); + }; + + const handleSubmit = async () => { + setInlineError(null); + + if (reason.trim().length < MIN_LEN) { + setInlineError(`Reason must be at least ${MIN_LEN} characters.`); + return; + } + + const selectedFiles = files.filter(Boolean) as File[]; + + try { + await mutateAsync({ adoptionId, raisedBy, reason, files: selectedFiles }); + toast.success("Dispute raised. Escrow paused."); + onClose(); + } catch (err) { + const msg = err instanceof Error ? err.message : "Failed to raise dispute."; + setInlineError(msg); + } + }; + + return ( +
+
e.stopPropagation()} + > + {!isPending && ( + + )} + +

Raise a dispute

+

+ Tell us why you're raising this dispute. +

+ +
+ +