mirror of
https://github.com/mosh-hamedani/helpdesk.git
synced 2026-05-21 11:58:19 +02:00
Add the ability to update tickets
This commit is contained in:
parent
66544ae286
commit
db29af6970
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 } } },
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue