Add the ability to update tickets

This commit is contained in:
Moshfegh Hamedani 2026-02-23 12:09:33 -08:00
parent 66544ae286
commit db29af6970
6 changed files with 293 additions and 69 deletions

View file

@ -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,

View file

@ -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<TicketDetail>(`/api/tickets/${id}`, {
assignedToId,
});
const updateMutation = useMutation({
mutationFn: async (body: Record<string, unknown>) => {
const { data } = await axios.patch<TicketDetail>(
`/api/tickets/${id}`,
body
);
return data;
},
onSuccess: () => {
@ -106,35 +106,102 @@ export default function TicketDetailPage() {
)}
{ticket && (
<>
<div>
<h1 className="text-2xl font-bold">{ticket.subject}</h1>
<div className="flex items-center gap-2 mt-2">
<Badge variant={statusVariant[ticket.status]}>
{ticket.status}
</Badge>
{ticket.category && (
<Badge variant="secondary">
{ticket.category.replace(/_/g, " ")}
</Badge>
)}
<div className="grid grid-cols-[1fr_auto] gap-6">
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">{ticket.subject}</h1>
<div className="mt-2 space-y-1 text-sm">
<div>
<span className="text-muted-foreground">From: </span>
{ticket.senderName} ({ticket.senderEmail})
</div>
<div>
<span className="text-muted-foreground">Created: </span>
{new Date(ticket.createdAt).toLocaleString()}
</div>
<div>
<span className="text-muted-foreground">Updated: </span>
{new Date(ticket.updatedAt).toLocaleString()}
</div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Message</CardTitle>
<CardDescription>
From {ticket.senderName}
</CardDescription>
</CardHeader>
<CardContent>
{ticket.bodyHtml ? (
<div
dangerouslySetInnerHTML={{ __html: ticket.bodyHtml }}
/>
) : (
<p className="whitespace-pre-wrap">{ticket.body}</p>
)}
</CardContent>
</Card>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">From: </span>
{ticket.senderName} ({ticket.senderEmail})
<div className="w-48 space-y-4 text-sm">
<div className="space-y-1">
<span className="text-muted-foreground">Status</span>
<Select
value={ticket.status}
onValueChange={(value) =>
updateMutation.mutate({ status: value })
}
>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{ticketStatuses.map((s) => (
<SelectItem key={s} value={s}>
{statusLabel[s]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">Assigned to: </span>
<div className="space-y-1">
<span className="text-muted-foreground">Category</span>
<Select
value={ticket.category ?? "none"}
onValueChange={(value) =>
updateMutation.mutate({
category: value === "none" ? null : value,
})
}
>
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">None</SelectItem>
{ticketCategories.map((c) => (
<SelectItem key={c} value={c}>
{categoryLabel[c]}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<span className="text-muted-foreground">Assigned To</span>
<Select
value={ticket.assignedTo?.id ?? "unassigned"}
onValueChange={(value) =>
assignMutation.mutate(value === "unassigned" ? null : value)
updateMutation.mutate({
assignedToId: value === "unassigned" ? null : value,
})
}
>
<SelectTrigger size="sm">
<SelectTrigger size="sm" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
@ -147,34 +214,8 @@ export default function TicketDetailPage() {
</SelectContent>
</Select>
</div>
<div>
<span className="text-muted-foreground">Created: </span>
{new Date(ticket.createdAt).toLocaleString()}
</div>
<div>
<span className="text-muted-foreground">Updated: </span>
{new Date(ticket.updatedAt).toLocaleString()}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Message</CardTitle>
<CardDescription>
From {ticket.senderName}
</CardDescription>
</CardHeader>
<CardContent>
{ticket.bodyHtml ? (
<div
dangerouslySetInnerHTML={{ __html: ticket.bodyHtml }}
/>
) : (
<p className="whitespace-pre-wrap">{ticket.body}</p>
)}
</CardContent>
</Card>
</>
</div>
)}
</div>
);

View file

@ -5,3 +5,9 @@ export const ticketCategories = [
] as const;
export type TicketCategory = (typeof ticketCategories)[number];
export const categoryLabel: Record<TicketCategory, string> = {
general_question: "General",
technical_question: "Technical",
refund_request: "Refund",
};

View file

@ -2,6 +2,12 @@ export const ticketStatuses = ["open", "resolved", "closed"] as const;
export type TicketStatus = (typeof ticketStatuses)[number];
export const statusLabel: Record<TicketStatus, string> = {
open: "Open",
resolved: "Resolved",
closed: "Closed",
};
export const statusVariant: Record<TicketStatus, "default" | "secondary" | "outline"> = {
open: "default",
resolved: "secondary",

View file

@ -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({

View file

@ -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 } } },
});