diff --git a/CLAUDE.md b/CLAUDE.md index 851ff0f..f574d0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,12 +45,15 @@ The client proxies `/api/*` requests to the server via Vite config (target is co - Define shared Zod schemas in the `core` package under `core/schemas/` (e.g. `core/schemas/users.ts`) and import them in both client and server (e.g. `import { createUserSchema } from "core/schemas/users"`) - Use Zod for validation (import from `zod/v4`) - Validate request bodies in route handlers using the shared `validate` helper (`import { validate } from "../lib/validate"`). It takes a Zod schema, the request body, and the `res` object — returns parsed data or `null` (after sending a 400 response). +- Parse and validate numeric ID route params with the shared `parseId` helper (`import { parseId } from "../lib/parse-id"`). Returns a positive integer or `null` for invalid values. - Do not wrap async route handlers in try/catch — Express 5 automatically catches rejected promises - Use the shared `Role` constant instead of hardcoded `"admin"` / `"agent"` strings (import from `core/constants/role.ts`, e.g. `import { Role } from "core/constants/role.ts"`) - Define shared constants and domain types in `core/constants/` as union types (not `enum` — the client has `erasableSyntaxOnly` enabled). Use `as const` objects when runtime access is needed (e.g. `Role`), and plain union types when only type checking is needed (e.g. `type TicketStatus = "open" | "resolved" | "closed"`). - Use React Hook Form with Zod resolver for client-side form validation (`useForm` + `zodResolver` from `@hookform/resolvers/zod`) - Use Axios for HTTP requests (not `fetch`) - Use TanStack React Query (`useQuery`, `useMutation`) for server state management (not `useEffect` + `useState`) +- Use the `ErrorAlert` component for error messages (`import ErrorAlert from "@/components/ErrorAlert"`). For static messages: ``. For mutation/query errors with automatic Axios message extraction: ``. +- Use the `ErrorMessage` component for field validation errors (`import ErrorMessage from "@/components/ErrorMessage"`): `{errors.name && }` ## Authentication @@ -79,4 +82,6 @@ The client proxies `/api/*` requests to the server via Vite config (target is co - **Framework**: Playwright - Use the `e2e-test-writer` agent for writing Playwright E2E tests - Run with `bun run test:e2e` from root -- Only use for navigation, auth redirects, and full-stack integration flows +- **Only use for things that truly require a real browser + server** — never duplicate what unit tests already cover +- Valid E2E scenarios: auth redirects, cross-page navigation, data persistence after reload, full-stack integration flows (e.g. webhook creates data → UI displays it) +- Invalid E2E scenarios: rendering, display logic, component states, API call verification, form validation, error messages — use component tests for these diff --git a/client/src/components/BackLink.tsx b/client/src/components/BackLink.tsx new file mode 100644 index 0000000..9112c44 --- /dev/null +++ b/client/src/components/BackLink.tsx @@ -0,0 +1,19 @@ +import { Link } from "react-router"; +import { ArrowLeft } from "lucide-react"; + +interface BackLinkProps { + to: string; + children: React.ReactNode; +} + +export default function BackLink({ to, children }: BackLinkProps) { + return ( + + + {children} + + ); +} diff --git a/client/src/components/ErrorAlert.tsx b/client/src/components/ErrorAlert.tsx new file mode 100644 index 0000000..57de2ab --- /dev/null +++ b/client/src/components/ErrorAlert.tsx @@ -0,0 +1,36 @@ +import axios from "axios"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { AlertCircle } from "lucide-react"; + +interface ErrorAlertProps { + /** Direct message string to display. */ + message?: string; + /** Error object — if an Axios error, the server message is extracted automatically. */ + error?: Error | null; + /** Fallback message when `error` doesn't contain a server message. */ + fallback?: string; + className?: string; +} + +export function getErrorMessage(error: unknown, fallback: string): string { + if (axios.isAxiosError(error)) { + return error.response?.data?.error ?? fallback; + } + return fallback; +} + +export default function ErrorAlert({ + message, + error, + fallback = "Something went wrong", + className, +}: ErrorAlertProps) { + const text = message ?? getErrorMessage(error, fallback); + + return ( + + + {text} + + ); +} diff --git a/client/src/components/ErrorMessage.tsx b/client/src/components/ErrorMessage.tsx new file mode 100644 index 0000000..d82cc48 --- /dev/null +++ b/client/src/components/ErrorMessage.tsx @@ -0,0 +1,3 @@ +export default function ErrorMessage({ message }: { message?: string }) { + return

{message}

; +} diff --git a/client/src/components/ReplyForm.test.tsx b/client/src/components/ReplyForm.test.tsx new file mode 100644 index 0000000..3be4adc --- /dev/null +++ b/client/src/components/ReplyForm.test.tsx @@ -0,0 +1,130 @@ +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import axios from "axios"; +import { renderWithQuery } from "@/test/render"; +import ReplyForm from "./ReplyForm"; + +vi.mock("axios"); +const mockedAxios = vi.mocked(axios, { deep: true }); + +const TICKET_ID = 42; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +function renderForm() { + const user = userEvent.setup(); + renderWithQuery(); + return { user }; +} + +describe("ReplyForm", () => { + it("should render textarea and submit button", () => { + renderForm(); + + expect(screen.getByPlaceholderText("Type your reply...")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Send Reply" })).toBeInTheDocument(); + }); + + it("should show validation error when submitting empty body", async () => { + const { user } = renderForm(); + + await user.click(screen.getByRole("button", { name: "Send Reply" })); + + await waitFor(() => { + expect(screen.getByText("Reply body is required")).toBeInTheDocument(); + }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it("should show validation error for whitespace-only body", async () => { + const { user } = renderForm(); + + await user.type(screen.getByPlaceholderText("Type your reply..."), " "); + await user.click(screen.getByRole("button", { name: "Send Reply" })); + + await waitFor(() => { + expect(screen.getByText("Reply body is required")).toBeInTheDocument(); + }); + expect(mockedAxios.post).not.toHaveBeenCalled(); + }); + + it("should call POST /api/tickets/:ticketId/replies with body on valid submit", async () => { + mockedAxios.post.mockResolvedValue({ data: { id: 1, body: "Hello" } }); + const { user } = renderForm(); + + await user.type(screen.getByPlaceholderText("Type your reply..."), "Hello"); + await user.click(screen.getByRole("button", { name: "Send Reply" })); + + await waitFor(() => { + expect(mockedAxios.post).toHaveBeenCalledWith( + `/api/tickets/${TICKET_ID}/replies`, + { body: "Hello" } + ); + }); + }); + + it("should clear the textarea after successful submission", async () => { + mockedAxios.post.mockResolvedValue({ data: { id: 1, body: "Hello" } }); + const { user } = renderForm(); + + const textarea = screen.getByPlaceholderText("Type your reply..."); + await user.type(textarea, "Hello"); + await user.click(screen.getByRole("button", { name: "Send Reply" })); + + await waitFor(() => { + expect(textarea).toHaveValue(""); + }); + }); + + it("should show 'Sending...' and disable button while submitting", async () => { + mockedAxios.post.mockReturnValue(new Promise(() => {})); + const { user } = renderForm(); + + await user.type(screen.getByPlaceholderText("Type your reply..."), "Hello"); + await user.click(screen.getByRole("button", { name: "Send Reply" })); + + await waitFor(() => { + const button = screen.getByRole("button", { name: "Sending..." }); + expect(button).toBeDisabled(); + }); + }); + + it("should display server error message from Axios response", async () => { + const error = new Error("Bad Request"); + Object.assign(error, { + response: { status: 400, data: { error: "Ticket not found" } }, + }); + mockedAxios.post.mockRejectedValue(error); + mockedAxios.isAxiosError.mockReturnValue(true); + const { user } = renderForm(); + + await user.type(screen.getByPlaceholderText("Type your reply..."), "Hello"); + await user.click(screen.getByRole("button", { name: "Send Reply" })); + + await waitFor(() => { + expect(screen.getByText("Ticket not found")).toBeInTheDocument(); + }); + }); + + it("should show generic error for non-Axios errors", async () => { + mockedAxios.post.mockRejectedValue(new Error("Network failure")); + mockedAxios.isAxiosError.mockReturnValue(false); + const { user } = renderForm(); + + await user.type(screen.getByPlaceholderText("Type your reply..."), "Hello"); + await user.click(screen.getByRole("button", { name: "Send Reply" })); + + await waitFor(() => { + expect(screen.getByText("Failed to send reply")).toBeInTheDocument(); + }); + }); + + it("should not show error alert before submission", () => { + renderForm(); + + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); +}); diff --git a/client/src/components/ReplyForm.tsx b/client/src/components/ReplyForm.tsx new file mode 100644 index 0000000..0d7d696 --- /dev/null +++ b/client/src/components/ReplyForm.tsx @@ -0,0 +1,63 @@ +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import axios from "axios"; +import { type Ticket } from "core/constants/ticket.ts"; +import { createReplySchema, type CreateReplyInput } from "core/schemas/replies.ts"; +import { Textarea } from "@/components/ui/textarea"; +import { Button } from "@/components/ui/button"; +import ErrorAlert from "@/components/ErrorAlert"; +import ErrorMessage from "@/components/ErrorMessage"; + +interface ReplyFormProps { + ticket: Ticket; +} + +export default function ReplyForm({ ticket }: ReplyFormProps) { + const ticketId = ticket.id; + const queryClient = useQueryClient(); + + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createReplySchema), + }); + + const mutation = useMutation({ + mutationFn: async (data: CreateReplyInput) => { + const { data: reply } = await axios.post( + `/api/tickets/${ticketId}/replies`, + data + ); + return reply; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["replies", ticketId] }); + reset(); + }, + }); + + return ( +
mutation.mutate(data))} className="space-y-3"> + {mutation.error && ( + + )} + +
+