From c7f0635fffb6dd7439d1e70639f36b914792e5fc Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Wed, 20 May 2026 00:23:16 +0000 Subject: [PATCH] GRO-1178: client-facing enhanced pet profile editor - PetForm: coat type dropdown, temperament display (read-only), medical alerts editor (add/remove/severity), preferred cuts tag input - PetProfiles: Medical tab shows severity badges, Grooming tab shows coat type + preferred cuts, Basic Info tab shows temperament score/flags - PetForm.test: component tests for all new interactions - Shared types updated: MedicalAlert, CoatType, AlertSeverity added Co-Authored-By: Paperclip --- src/__tests__/PetForm.test.tsx | 152 +++++++++++++++ src/portal/sections/PetForm.tsx | 288 ++++++++++++++++++++++++---- src/portal/sections/PetProfiles.tsx | 136 +++++++++---- 3 files changed, 507 insertions(+), 69 deletions(-) create mode 100644 src/__tests__/PetForm.test.tsx diff --git a/src/__tests__/PetForm.test.tsx b/src/__tests__/PetForm.test.tsx new file mode 100644 index 0000000..03aef26 --- /dev/null +++ b/src/__tests__/PetForm.test.tsx @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PetForm } from "../portal/sections/PetForm.js"; +import type { Pet } from "../../../../packages/types/src/index.js"; + +const BASE_PET: Pet = { + id: "pet-1", + clientId: "client-1", + name: "Buddy", + species: "dog", + breed: "Labrador", + weightKg: 25, + dateOfBirth: "2020-03-15T00:00:00.000Z", + healthAlerts: null, + groomingNotes: null, + cutStyle: null, + shampooPreference: null, + specialCareNotes: null, + customFields: {}, + coatType: null, + preferredCuts: [], + medicalAlerts: [], + createdAt: "2024-01-01T00:00:00.000Z", + updatedAt: "2024-01-01T00:00:00.000Z", +}; + +describe("PetForm", () => { + const onSave = vi.fn(); + const onCancel = vi.fn(); + + beforeEach(() => { + onSave.mockClear(); + onCancel.mockClear(); + }); + + // ── Coat type ─────────────────────────────────────────────────────────────── + + it("allows coat type selection from dropdown", () => { + render(); + const select = screen.getByRole("combobox", { name: /coat type/i }); + fireEvent.change(select, { target: { value: "curly" } }); + expect((select as HTMLSelectElement).value).toBe("curly"); + }); + + it("persists coat type on save", () => { + render(); + fireEvent.change(screen.getByRole("combobox", { name: /coat type/i }), { target: { value: "double" } }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ coatType: "double" }) + ); + }); + + // ── Preferred cuts tag input ──────────────────────────────────────────────── + + it("adds a cut when Enter is pressed", () => { + render(); + const input = screen.getByPlaceholder(/type a cut name/i); + fireEvent.change(input, { target: { value: "Puppy Cut" } }); + fireEvent.keyDown(input, { key: "Enter" }); + expect(screen.getByText("Puppy Cut")).toBeTruthy(); + }); + + it("adds a cut when the + button is clicked", () => { + render(); + const input = screen.getByPlaceholder(/type a cut name/i); + fireEvent.change(input, { target: { value: "Teddy Bear" } }); + fireEvent.click(screen.getByRole("button", { name: /add/i })); + expect(screen.getByText("Teddy Bear")).toBeTruthy(); + }); + + it("removes a cut when X is clicked", () => { + const petWithCuts: Pet = { + ...BASE_PET, + preferredCuts: ["Puppy Cut", "Teddy Bear"], + }; + render(); + const puppyCutSpans = screen.getAllByText("Puppy Cut"); + const puppyCutTag = puppyCutSpans[0].closest("span"); + const removeBtn = puppyCutTag?.querySelector("button") ?? screen.getAllByTitle("")[0]; + fireEvent.click(removeBtn); + expect(screen.queryByText("Puppy Cut")).toBeNull(); + expect(screen.getByText("Teddy Bear")).toBeTruthy(); + }); + + it("includes preferred cuts in save payload", () => { + render(); + fireEvent.change(screen.getByPlaceholder(/type a cut name/i), { target: { value: "Puppy Cut" } }); + fireEvent.keyDown(screen.getByPlaceholder(/type a cut name/i), { key: "Enter" }); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ preferredCuts: ["Puppy Cut"] }) + ); + }); + + // ── Medical alerts ─────────────────────────────────────────────────────────── + + it("adds a medical alert", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /add alert/i })); + expect(screen.getByPlaceholder(/alert type/i)).toBeTruthy(); + }); + + it("removes a medical alert", () => { + const petWithAlert: Pet = { + ...BASE_PET, + medicalAlerts: [{ id: "alert-1", type: "Allergic to chicken", description: "Causes hives", severity: "high" }], + }; + render(); + const removeBtn = screen.getAllByRole("button", { name: "" })[0]; + fireEvent.click(removeBtn); + expect(screen.queryByText("Allergic to chicken")).toBeNull(); + }); + + it("validates alert type is non-empty", () => { + render(); + fireEvent.click(screen.getByRole("button", { name: /add alert/i })); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + expect(screen.getByText(/type is required/i)).toBeTruthy(); + expect(onSave).not.toHaveBeenCalled(); + }); + + it("shows medical alerts in save payload", () => { + const petWithAlert: Pet = { + ...BASE_PET, + medicalAlerts: [{ id: "alert-1", type: "Sensitive skin", description: "Use hypoallergenic shampoo only", severity: "medium" }], + }; + render(); + fireEvent.click(screen.getByRole("button", { name: /save/i })); + expect(onSave).toHaveBeenCalledWith( + expect.objectContaining({ + medicalAlerts: expect.arrayContaining([ + expect.objectContaining({ type: "Sensitive skin", severity: "medium" }), + ]), + }) + ); + }); + + // ── Temperament read-only display ───────────────────────────────────────────── + + it("displays temperament score as read-only stars", () => { + const petWithTemperament: Pet = { + ...BASE_PET, + temperamentScore: 4, + temperamentFlags: ["Anxious", "Good with kids"], + }; + render(); + expect(screen.getByText("(/4/5)")).toBeTruthy(); + expect(screen.getByText("Anxious")).toBeTruthy(); + expect(screen.getByText("Good with kids")).toBeTruthy(); + }); +}); \ No newline at end of file diff --git a/src/portal/sections/PetForm.tsx b/src/portal/sections/PetForm.tsx index 626b042..6d50117 100644 --- a/src/portal/sections/PetForm.tsx +++ b/src/portal/sections/PetForm.tsx @@ -1,6 +1,9 @@ import { useState } from "react"; -import { X, Save } from "lucide-react"; -import type { Pet } from "../mockData.js"; +import { X, Save, Plus, Star } from "lucide-react"; +import type { Pet, MedicalAlert, CoatType, AlertSeverity } from "../../../../packages/types/src/index.js"; + +const COAT_TYPES: CoatType[] = ["double", "single", "wire", "curly", "smooth", "long", "short", "hairless"]; +const SEVERITY_OPTIONS: AlertSeverity[] = ["low", "medium", "high"]; interface Props { pet?: Pet; @@ -8,63 +11,274 @@ interface Props { onCancel: () => void; } +function newAlert(): Omit { + return { type: "", description: "", severity: "low" }; +} + export function PetForm({ pet, onSave, onCancel }: Props) { const [name, setName] = useState(pet?.name ?? ""); const [breed, setBreed] = useState(pet?.breed ?? ""); - const [weight, setWeight] = useState(pet?.weight ?? 0); - const [notes, setNotes] = useState(pet?.allergies ?? ""); + const [weight, setWeight] = useState(pet?.weightKg ?? 0); + const [notes, setNotes] = useState(pet?.healthAlerts ?? ""); + const [coatType, setCoatType] = useState((pet?.coatType as CoatType) ?? ""); + const [preferredCuts, setPreferredCuts] = useState(pet?.preferredCuts ?? []); + const [cutInput, setCutInput] = useState(""); + const [alerts, setAlerts] = useState[]>( + pet?.medicalAlerts?.map(({ type, description, severity }) => ({ type, description, severity })) ?? [] + ); + const [alertErrors, setAlertErrors] = useState>({}); + + function addAlert() { + setAlerts(prev => [...prev, newAlert()]); + } + + function updateAlert(idx: number, field: keyof Omit, value: string) { + setAlerts(prev => prev.map((a, i) => i === idx ? { ...a, [field]: value } : a)); + setAlertErrors(prev => { const e = { ...prev }; delete e[idx]; return e; }); + } + + function removeAlert(idx: number) { + setAlerts(prev => prev.filter((_, i) => i !== idx)); + setAlertErrors(prev => { const e = { ...prev }; delete e[idx]; return e; }); + } + + function addCut() { + const trimmed = cutInput.trim(); + if (trimmed && !preferredCuts.includes(trimmed)) { + setPreferredCuts(prev => [...prev, trimmed]); + } + setCutInput(""); + } + + function removeCut(cut: string) { + setPreferredCuts(prev => prev.filter(c => c !== cut)); + } + + function handleCutKey(e: React.KeyboardEvent) { + if (e.key === "Enter") { e.preventDefault(); addCut(); } + } + + function validateAlerts(): boolean { + const errors: Record = {}; + alerts.forEach((a, i) => { + if (!a.type.trim()) errors[i] = "Type is required"; + else if (a.severity === "medium" && !a.description.trim()) errors[i] = "Description required at medium/high severity"; + }); + setAlertErrors(errors); + return Object.keys(errors).length === 0; + } function handleSubmit(e: React.FormEvent) { e.preventDefault(); if (!pet) return; - onSave({ ...pet, name, breed, weight, allergies: notes }); + if (!handleCutKey) {} // noop reference + if (alerts.length > 0 && !validateAlerts()) return; + + const savedPet: Pet = { + ...pet, + name, + breed: breed || null, + weightKg: weight || null, + healthAlerts: notes, + coatType: coatType || null, + preferredCuts, + medicalAlerts: alerts.map((a, i) => ({ ...a, id: pet.medicalAlerts?.[i]?.id ?? crypto.randomUUID() })), + }; + onSave(savedPet); } + const temperamentScore = pet?.temperamentScore; + const temperamentFlags = pet?.temperamentFlags ?? []; + return (
-

{pet ? "Edit Pet" : "Add Pet"}

+

{pet?.id ? "Edit Pet" : "Add Pet"}

-
-
- - setName(e.target.value)} - className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" - /> + + + {/* Basic Info */} +
+
+ + setName(e.target.value)} + required + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ + setBreed(e.target.value)} + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ + setWeight(Number(e.target.value))} + min="0" + step="0.1" + className="w-full border border-stone-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-(--color-accent)" + /> +
+
+ +