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:
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user