Phase 1b: React Query + Vitest on client

- @tanstack/react-query v5 with QueryClientProvider at app root
- client/src/api/queries.ts: query-key factory, hooks for tickets, ticket, audit,
  comments, users, CTI tree + cascade, plus full mutation set
  (create/update/delete ticket, add/delete comment, CTI CRUD, user CRUD)
- All page-level useEffect + useState fetching replaced:
  Dashboard, MyTickets, TicketDetail, NewTicket, admin/CTI, admin/Users
- Dashboard preserves 300ms debounced search via separate debouncedSearch state
- CTISelect cascades via useCategories / useTypes(categoryId) / useItems(typeId);
  dependent hooks disabled until parent selected
- vitest + @testing-library/react + jsdom; 6 client tests cover SeverityBadge + StatusBadge

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:35:09 -04:00
parent aff52e5672
commit 4eae11b5b0
16 changed files with 1722 additions and 3401 deletions
+33 -49
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { format, formatDistanceToNow } from 'date-fns';
import ReactMarkdown from 'react-markdown';
@@ -16,15 +16,23 @@ import {
ChevronDown,
ChevronRight,
} from 'lucide-react';
import api from '../api/client';
import Layout from '../components/Layout';
import Modal from '../components/Modal';
import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import CTISelect from '../components/CTISelect';
import Avatar from '../components/Avatar';
import { Ticket, TicketStatus, User, Comment, AuditLog } from '../types';
import { TicketStatus } from '../types';
import { useAuth } from '../contexts/AuthContext';
import {
useTicket,
useTicketAudit,
useUpdateTicket,
useDeleteTicket,
useUsers,
useAddComment,
useDeleteComment,
} from '../api/queries';
type Tab = 'overview' | 'comments' | 'audit';
@@ -67,15 +75,10 @@ export default function TicketDetail() {
const navigate = useNavigate();
const { user: authUser } = useAuth();
const [ticket, setTicket] = useState<Ticket | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>('overview');
const [editing, setEditing] = useState(false);
const [reroutingCTI, setReroutingCTI] = useState(false);
const [commentBody, setCommentBody] = useState('');
const [submittingComment, setSubmittingComment] = useState(false);
const [preview, setPreview] = useState(false);
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
@@ -87,6 +90,15 @@ export default function TicketDetail() {
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
const [expandedCommentDates, setExpandedCommentDates] = useState<Set<string>>(new Set());
const { data: ticket, isLoading } = useTicket(id);
const { data: users = [] } = useUsers();
const { data: auditLogs = [] } = useTicketAudit(id, tab === 'audit');
const updateTicket = useUpdateTicket();
const deleteTicketMutation = useDeleteTicket();
const addComment = useAddComment(id);
const deleteCommentMutation = useDeleteComment(id);
const toggleDate = (key: string) =>
setExpandedDates((prev) => {
const next = new Set(prev);
@@ -95,36 +107,19 @@ export default function TicketDetail() {
return next;
});
const toggleCommentDate = (id: string) =>
const toggleCommentDate = (commentId: string) =>
setExpandedCommentDates((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
if (next.has(commentId)) next.delete(commentId);
else next.add(commentId);
return next;
});
const isAdmin = authUser?.role === 'ADMIN';
useEffect(() => {
Promise.all([api.get<Ticket>(`/tickets/${id}`), api.get<User[]>('/users')])
.then(([tRes, uRes]) => {
setTicket(tRes.data);
setUsers(uRes.data);
})
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (tab === 'audit' && ticket) {
api.get<AuditLog[]>(`/tickets/${id}/audit`).then((r) => setAuditLogs(r.data));
}
}, [tab, ticket, id]);
const patch = async (payload: Record<string, unknown>) => {
if (!ticket) return;
const res = await api.patch<Ticket>(`/tickets/${ticket.displayId}`, payload);
setTicket(res.data);
return res.data;
await updateTicket.mutateAsync({ id: ticket.displayId, data: payload });
};
const startEdit = () => {
@@ -152,30 +147,20 @@ export default function TicketDetail() {
const deleteTicket = async () => {
if (!ticket || !confirm('Delete this ticket? This cannot be undone.')) return;
await api.delete(`/tickets/${ticket.displayId}`);
await deleteTicketMutation.mutateAsync(ticket.displayId);
navigate('/');
};
const submitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!ticket || !commentBody.trim()) return;
setSubmittingComment(true);
try {
const res = await api.post<Comment>(`/tickets/${ticket.displayId}/comments`, {
body: commentBody.trim(),
});
setTicket((t) => (t ? { ...t, comments: [...(t.comments ?? []), res.data] } : t));
setCommentBody('');
setPreview(false);
} finally {
setSubmittingComment(false);
}
await addComment.mutateAsync(commentBody.trim());
setCommentBody('');
setPreview(false);
};
const deleteComment = async (commentId: string) => {
if (!ticket) return;
await api.delete(`/tickets/${ticket.displayId}/comments/${commentId}`);
setTicket((t) => (t ? { ...t, comments: t.comments?.filter((c) => c.id !== commentId) } : t));
const handleDeleteComment = async (commentId: string) => {
await deleteCommentMutation.mutateAsync(commentId);
};
const toggleLog = (logId: string) => {
@@ -187,7 +172,7 @@ export default function TicketDetail() {
});
};
if (loading) {
if (isLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
@@ -210,7 +195,6 @@ export default function TicketDetail() {
const commentCount = ticket.comments?.length ?? 0;
const agentUsers = users.filter((u) => u.role !== 'SERVICE');
// Status options: CLOSED only for admins
const statusOptions: { value: TicketStatus; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
@@ -362,7 +346,7 @@ export default function TicketDetail() {
</div>
{(comment.authorId === authUser?.id || isAdmin) && (
<button
onClick={() => deleteComment(comment.id)}
onClick={() => handleDeleteComment(comment.id)}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
@@ -426,7 +410,7 @@ export default function TicketDetail() {
<span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
<button
type="submit"
disabled={submittingComment || !commentBody.trim()}
disabled={addComment.isPending || !commentBody.trim()}
className="flex items-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<Send size={13} />