diff --git a/client/src/pages/TicketDetailPage.test.tsx b/client/src/pages/TicketDetailPage.test.tsx index ff64117..49db176 100644 --- a/client/src/pages/TicketDetailPage.test.tsx +++ b/client/src/pages/TicketDetailPage.test.tsx @@ -88,8 +88,9 @@ describe("TicketDetailPage", () => { ).toBeInTheDocument(); }); - expect(screen.getByText("open")).toBeInTheDocument(); - expect(screen.getByText("technical question")).toBeInTheDocument(); + const comboboxes = screen.getAllByRole("combobox"); + expect(comboboxes[0]).toHaveTextContent("Open"); + expect(comboboxes[1]).toHaveTextContent("Technical Question"); expect( screen.getByText(/Alice Smith \(alice@example\.com\)/) ).toBeInTheDocument(); @@ -133,7 +134,7 @@ describe("TicketDetailPage", () => { }); }); - it("should show 'Unassigned' in the dropdown when ticket has no assignee", async () => { + it("should show 'Unassigned' in the assignee dropdown when ticket has no assignee", async () => { mockedAxios.get.mockImplementation((url: string) => { if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); if (url === "/api/agents") @@ -148,8 +149,8 @@ describe("TicketDetailPage", () => { ).toBeInTheDocument(); }); - const trigger = screen.getByRole("combobox"); - expect(trigger).toHaveTextContent("Unassigned"); + const comboboxes = screen.getAllByRole("combobox"); + expect(comboboxes[2]).toHaveTextContent("Unassigned"); }); it("should show the assigned agent name in the dropdown", async () => { @@ -172,8 +173,8 @@ describe("TicketDetailPage", () => { ).toBeInTheDocument(); }); - const trigger = screen.getByRole("combobox"); - expect(trigger).toHaveTextContent("Jane Doe"); + const comboboxes = screen.getAllByRole("combobox"); + expect(comboboxes[2]).toHaveTextContent("Jane Doe"); }); it("should call PATCH with assignedToId when selecting an agent", async () => { @@ -195,7 +196,8 @@ describe("TicketDetailPage", () => { ).toBeInTheDocument(); }); - await user.click(screen.getByRole("combobox")); + const comboboxes = screen.getAllByRole("combobox"); + await user.click(comboboxes[2]); await waitFor(() => { expect(screen.getByRole("option", { name: "Jane Doe" })).toBeInTheDocument(); @@ -234,7 +236,8 @@ describe("TicketDetailPage", () => { ).toBeInTheDocument(); }); - await user.click(screen.getByRole("combobox")); + const comboboxes = screen.getAllByRole("combobox"); + await user.click(comboboxes[2]); await waitFor(() => { expect( @@ -251,6 +254,168 @@ describe("TicketDetailPage", () => { }); }); + it("should display current status and all status options in dropdown", async () => { + const user = userEvent.setup(); + mockedAxios.get.mockImplementation((url: string) => { + if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); + if (url === "/api/agents") + return Promise.resolve({ data: { agents: mockAgents } }); + return Promise.reject(new Error("unexpected url")); + }); + renderPage(); + + await waitFor(() => { + expect( + screen.getByText("Cannot login to my account") + ).toBeInTheDocument(); + }); + + const comboboxes = screen.getAllByRole("combobox"); + expect(comboboxes[0]).toHaveTextContent("Open"); + + await user.click(comboboxes[0]); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "Open" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Resolved" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Closed" })).toBeInTheDocument(); + }); + }); + + it("should call PATCH with status when selecting a status", async () => { + const user = userEvent.setup(); + mockedAxios.get.mockImplementation((url: string) => { + if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); + if (url === "/api/agents") + return Promise.resolve({ data: { agents: mockAgents } }); + return Promise.reject(new Error("unexpected url")); + }); + mockedAxios.patch.mockResolvedValue({ + data: { ...mockTicket, status: "resolved" }, + }); + renderPage(); + + await waitFor(() => { + expect( + screen.getByText("Cannot login to my account") + ).toBeInTheDocument(); + }); + + const comboboxes = screen.getAllByRole("combobox"); + await user.click(comboboxes[0]); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "Resolved" })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("option", { name: "Resolved" })); + + await waitFor(() => { + expect(mockedAxios.patch).toHaveBeenCalledWith("/api/tickets/1", { + status: "resolved", + }); + }); + }); + + it("should display current category and all category options in dropdown", async () => { + const user = userEvent.setup(); + mockedAxios.get.mockImplementation((url: string) => { + if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); + if (url === "/api/agents") + return Promise.resolve({ data: { agents: mockAgents } }); + return Promise.reject(new Error("unexpected url")); + }); + renderPage(); + + await waitFor(() => { + expect( + screen.getByText("Cannot login to my account") + ).toBeInTheDocument(); + }); + + const comboboxes = screen.getAllByRole("combobox"); + expect(comboboxes[1]).toHaveTextContent("Technical Question"); + + await user.click(comboboxes[1]); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "None" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "General Question" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Technical Question" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "Refund Request" })).toBeInTheDocument(); + }); + }); + + it("should call PATCH with category when selecting a category", async () => { + const user = userEvent.setup(); + mockedAxios.get.mockImplementation((url: string) => { + if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); + if (url === "/api/agents") + return Promise.resolve({ data: { agents: mockAgents } }); + return Promise.reject(new Error("unexpected url")); + }); + mockedAxios.patch.mockResolvedValue({ + data: { ...mockTicket, category: "refund_request" }, + }); + renderPage(); + + await waitFor(() => { + expect( + screen.getByText("Cannot login to my account") + ).toBeInTheDocument(); + }); + + const comboboxes = screen.getAllByRole("combobox"); + await user.click(comboboxes[1]); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "Refund Request" })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("option", { name: "Refund Request" })); + + await waitFor(() => { + expect(mockedAxios.patch).toHaveBeenCalledWith("/api/tickets/1", { + category: "refund_request", + }); + }); + }); + + it("should call PATCH with null category when selecting None", async () => { + const user = userEvent.setup(); + mockedAxios.get.mockImplementation((url: string) => { + if (url === "/api/tickets/1") return Promise.resolve({ data: mockTicket }); + if (url === "/api/agents") + return Promise.resolve({ data: { agents: mockAgents } }); + return Promise.reject(new Error("unexpected url")); + }); + mockedAxios.patch.mockResolvedValue({ + data: { ...mockTicket, category: null }, + }); + renderPage(); + + await waitFor(() => { + expect( + screen.getByText("Cannot login to my account") + ).toBeInTheDocument(); + }); + + const comboboxes = screen.getAllByRole("combobox"); + await user.click(comboboxes[1]); + + await waitFor(() => { + expect(screen.getByRole("option", { name: "None" })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole("option", { name: "None" })); + + await waitFor(() => { + expect(mockedAxios.patch).toHaveBeenCalledWith("/api/tickets/1", { + category: null, + }); + }); + }); + it("should display the ticket body as HTML when bodyHtml is present", async () => { const htmlTicket = { ...mockTicket, diff --git a/client/src/pages/TicketDetailPage.tsx b/client/src/pages/TicketDetailPage.tsx index c1ba71a..81ccefc 100644 --- a/client/src/pages/TicketDetailPage.tsx +++ b/client/src/pages/TicketDetailPage.tsx @@ -1,10 +1,9 @@ import { useParams, Link } from "react-router"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import axios from "axios"; -import { type TicketStatus, statusVariant } from "core/constants/ticket-status.ts"; -import { type TicketCategory } from "core/constants/ticket-category.ts"; +import { type TicketStatus, ticketStatuses, statusLabel } from "core/constants/ticket-status.ts"; +import { type TicketCategory, ticketCategories, categoryLabel } from "core/constants/ticket-category.ts"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Select, @@ -61,11 +60,12 @@ export default function TicketDetailPage() { }, }); - const assignMutation = useMutation({ - mutationFn: async (assignedToId: string | null) => { - const { data } = await axios.patch(`/api/tickets/${id}`, { - assignedToId, - }); + const updateMutation = useMutation({ + mutationFn: async (body: Record) => { + const { data } = await axios.patch( + `/api/tickets/${id}`, + body + ); return data; }, onSuccess: () => { @@ -106,35 +106,102 @@ export default function TicketDetailPage() { )} {ticket && ( - <> -
-

{ticket.subject}

-
- - {ticket.status} - - {ticket.category && ( - - {ticket.category.replace(/_/g, " ")} - - )} +
+
+
+

{ticket.subject}

+
+
+ From: + {ticket.senderName} ({ticket.senderEmail}) +
+
+ Created: + {new Date(ticket.createdAt).toLocaleString()} +
+
+ Updated: + {new Date(ticket.updatedAt).toLocaleString()} +
+
+ + + + Message + + From {ticket.senderName} + + + + {ticket.bodyHtml ? ( +
+ ) : ( +

{ticket.body}

+ )} + +
-
-
- From: - {ticket.senderName} ({ticket.senderEmail}) +
+
+ Status +
-
- Assigned to: + +
+ Category + +
+ +
+ Assigned To
-
- Created: - {new Date(ticket.createdAt).toLocaleString()} -
-
- Updated: - {new Date(ticket.updatedAt).toLocaleString()} -
- - - - Message - - From {ticket.senderName} - - - - {ticket.bodyHtml ? ( -
- ) : ( -

{ticket.body}

- )} - - - +
)}
); diff --git a/core/constants/ticket-category.ts b/core/constants/ticket-category.ts index 76f33ac..46032b3 100644 --- a/core/constants/ticket-category.ts +++ b/core/constants/ticket-category.ts @@ -5,3 +5,9 @@ export const ticketCategories = [ ] as const; export type TicketCategory = (typeof ticketCategories)[number]; + +export const categoryLabel: Record = { + general_question: "General", + technical_question: "Technical", + refund_request: "Refund", +}; diff --git a/core/constants/ticket-status.ts b/core/constants/ticket-status.ts index 9cdeffc..5d6f0d9 100644 --- a/core/constants/ticket-status.ts +++ b/core/constants/ticket-status.ts @@ -2,6 +2,12 @@ export const ticketStatuses = ["open", "resolved", "closed"] as const; export type TicketStatus = (typeof ticketStatuses)[number]; +export const statusLabel: Record = { + open: "Open", + resolved: "Resolved", + closed: "Closed", +}; + export const statusVariant: Record = { open: "default", resolved: "secondary", diff --git a/core/schemas/tickets.ts b/core/schemas/tickets.ts index 1eb94f2..2af2db1 100644 --- a/core/schemas/tickets.ts +++ b/core/schemas/tickets.ts @@ -22,8 +22,10 @@ const sortableColumns = [ export type TicketSortField = (typeof sortableColumns)[number]; -export const assignTicketSchema = z.object({ - assignedToId: z.string().nullable(), +export const updateTicketSchema = z.object({ + assignedToId: z.string().nullable().optional(), + status: z.enum(ticketStatuses).optional(), + category: z.enum(ticketCategories).nullable().optional(), }); export const ticketListQuerySchema = z.object({ diff --git a/server/src/routes/tickets.ts b/server/src/routes/tickets.ts index 369b09e..fbbff8f 100644 --- a/server/src/routes/tickets.ts +++ b/server/src/routes/tickets.ts @@ -1,7 +1,7 @@ import { Router } from "express"; import { requireAuth } from "../middleware/require-auth"; import { validate } from "../lib/validate"; -import { ticketListQuerySchema, assignTicketSchema } from "core/schemas/tickets.ts"; +import { ticketListQuerySchema, updateTicketSchema } from "core/schemas/tickets.ts"; import prisma from "../db"; import type { Prisma } from "../generated/prisma/client"; @@ -80,7 +80,7 @@ router.patch("/:id", requireAuth, async (req, res) => { return; } - const data = validate(assignTicketSchema, req.body, res); + const data = validate(updateTicketSchema, req.body, res); if (!data) return; if (data.assignedToId) { @@ -101,7 +101,11 @@ router.patch("/:id", requireAuth, async (req, res) => { const updated = await prisma.ticket.update({ where: { id }, - data: { assignedToId: data.assignedToId }, + data: { + ...("assignedToId" in data && { assignedToId: data.assignedToId }), + ...("status" in data && { status: data.status }), + ...("category" in data && { category: data.category }), + }, include: { assignedTo: { select: { id: true, name: true } } }, });