feat(GRO-1179): add PetProfileCard component with medical alert severity badges
- Create PetProfileCard fetching from GET /api/pets/:id/profile-summary - Displays: pet photo/name/breed/age/weight, coat type badge, temperament score (1-5 dots) + flag badges, medical alerts (severity-colored), preferred cuts, recent visits, next appointment - Loading skeleton and error/empty states - Integrate into Appointments booking form after pet selection - Integrate into ClientDetailPage as expandable card per pet - Export PetProfileSummary + NextAppointment types in @groombook/types - Add PetProfileCard tests covering full data, empty data, loading, error Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { PetProfileCard } from "../components/PetProfileCard.js";
|
||||
|
||||
const FULL_SUMMARY = {
|
||||
id: "pet-1",
|
||||
name: "Buddy",
|
||||
breed: "Golden Retriever",
|
||||
dateOfBirth: "2022-03-15",
|
||||
weightKg: 30.5,
|
||||
coatType: "double",
|
||||
temperamentScore: 4,
|
||||
temperamentFlags: ["anxious", "friendly"],
|
||||
medicalAlerts: [
|
||||
{ id: "a1", type: "allergy", description: "Chicken allergy", severity: "high" },
|
||||
{ id: "a2", type: "condition", description: "Hip dysplasia", severity: "medium" },
|
||||
],
|
||||
preferredCuts: ["teddy bear", "puppy cut"],
|
||||
recentVisits: [
|
||||
{ id: "v1", petId: "pet-1", appointmentId: "appt-1", staffId: "staff-1", cutStyle: "teddy bear", productsUsed: "oat shampoo", notes: "Good boy", groomedAt: "2025-05-01T10:00:00Z", createdAt: "2025-05-01T10:00:00Z" },
|
||||
{ id: "v2", petId: "pet-1", appointmentId: "appt-2", staffId: "staff-2", cutStyle: "puppy cut", productsUsed: null, notes: null, groomedAt: "2025-04-01T10:00:00Z", createdAt: "2025-04-01T10:00:00Z" },
|
||||
],
|
||||
nextAppointment: { id: "appt-3", startTime: "2025-06-01T09:00:00Z", serviceName: "Full groom" },
|
||||
};
|
||||
|
||||
const EMPTY_SUMMARY = {
|
||||
id: "pet-2",
|
||||
name: "Whiskers",
|
||||
breed: null,
|
||||
dateOfBirth: null,
|
||||
weightKg: null,
|
||||
coatType: null,
|
||||
temperamentScore: null,
|
||||
temperamentFlags: [],
|
||||
medicalAlerts: [],
|
||||
preferredCuts: [],
|
||||
recentVisits: [],
|
||||
nextAppointment: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("PetProfileCard", () => {
|
||||
it("shows loading skeleton while fetching", () => {
|
||||
global.fetch = vi.fn(() => new Promise(() => {})) as unknown as typeof fetch;
|
||||
render(<PetProfileCard petId="pet-1" />);
|
||||
expect(screen.getByText(/loading/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders full profile data correctly", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => FULL_SUMMARY,
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetProfileCard petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Buddy")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText("Golden Retriever")).toBeInTheDocument();
|
||||
// age computed from DOB
|
||||
expect(screen.getByText(/yr/)).toBeInTheDocument();
|
||||
// weight
|
||||
expect(screen.getByText(/30.5 kg/)).toBeInTheDocument();
|
||||
// coat type badge
|
||||
expect(screen.getByText("double")).toBeInTheDocument();
|
||||
// medical alerts
|
||||
expect(screen.getByText("Chicken allergy")).toBeInTheDocument();
|
||||
expect(screen.getByText("Hip dysplasia")).toBeInTheDocument();
|
||||
// preferred cuts
|
||||
expect(screen.getByText("teddy bear")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("displays severity-colored badges for medical alerts", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => FULL_SUMMARY,
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetProfileCard petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Chicken allergy")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const highAlert = screen.getByText("Chicken allergy").closest("span");
|
||||
expect(highAlert).toHaveStyle({ color: "#dc2626" }); // high = red
|
||||
|
||||
const mediumAlert = screen.getByText("Hip dysplasia").closest("span");
|
||||
expect(mediumAlert).toHaveStyle({ color: "#d97706" }); // medium = amber
|
||||
});
|
||||
|
||||
it("handles empty pet data gracefully", async () => {
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => EMPTY_SUMMARY,
|
||||
} as Response)
|
||||
) as unknown as typeof fetch;
|
||||
|
||||
render(<PetProfileCard petId="pet-2" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Whiskers")).toBeInTheDocument();
|
||||
});
|
||||
// no section labels with no data
|
||||
expect(screen.queryByText(/medical alerts/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/preferred cuts/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(/recent visits/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error message on fetch failure", async () => {
|
||||
global.fetch = vi.fn(() => Promise.reject(new Error("network error"))) as unknown as typeof fetch;
|
||||
|
||||
render(<PetProfileCard petId="pet-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("refetches when petId changes", async () => {
|
||||
const fetchMock = vi.fn((url: string) => {
|
||||
if ((url as string).includes("pet-1")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ...FULL_SUMMARY, name: "Buddy" }),
|
||||
} as Response);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ...EMPTY_SUMMARY, name: "Whiskers" }),
|
||||
} as Response);
|
||||
}) as unknown as typeof fetch;
|
||||
global.fetch = fetchMock;
|
||||
|
||||
const { rerender } = render(<PetProfileCard petId="pet-1" />);
|
||||
await waitFor(() => expect(screen.getByText("Buddy")).toBeInTheDocument());
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-1/profile-summary");
|
||||
|
||||
rerender(<PetProfileCard petId="pet-2" />);
|
||||
await waitFor(() => expect(screen.getByText("Whiskers")).toBeInTheDocument());
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/pets/pet-2/profile-summary");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user