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
+248
View File
@@ -0,0 +1,248 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import api from './client';
import { Ticket, Category, CTIType, Item, User, AuditLog, Comment } from '../types';
// ── Keys ─────────────────────────────────────────────────────────────────────
export const qk = {
tickets: (filters?: Record<string, string | number | undefined>) =>
['tickets', filters ?? {}] as const,
ticket: (id: string) => ['ticket', id] as const,
ticketAudit: (id: string) => ['ticket', id, 'audit'] as const,
categories: () => ['cti', 'categories'] as const,
types: (categoryId?: string) => ['cti', 'types', categoryId ?? null] as const,
items: (typeId?: string) => ['cti', 'items', typeId ?? null] as const,
users: () => ['users'] as const,
};
// ── Tickets ──────────────────────────────────────────────────────────────────
export function useTickets(params: Record<string, string | number | undefined> = {}) {
// Strip undefined values for a stable key
const clean: Record<string, string | number> = {};
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== '' && v !== null) clean[k] = v;
});
return useQuery({
queryKey: qk.tickets(clean),
queryFn: async () => {
const res = await api.get<Ticket[]>('/tickets', { params: clean });
return res.data;
},
placeholderData: keepPreviousData,
});
}
export function useTicket(id: string | undefined) {
return useQuery({
queryKey: qk.ticket(id ?? ''),
queryFn: async () => (await api.get<Ticket>(`/tickets/${id}`)).data,
enabled: !!id,
});
}
export function useTicketAudit(id: string | undefined, enabled = true) {
return useQuery({
queryKey: qk.ticketAudit(id ?? ''),
queryFn: async () => (await api.get<AuditLog[]>(`/tickets/${id}/audit`)).data,
enabled: !!id && enabled,
});
}
export function useCreateTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Record<string, unknown>) =>
(await api.post<Ticket>('/tickets', data)).data,
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickets'] });
},
});
}
export function useUpdateTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, unknown> }) =>
(await api.patch<Ticket>(`/tickets/${id}`, data)).data,
onSuccess: (ticket) => {
qc.setQueryData(qk.ticket(ticket.displayId), ticket);
qc.invalidateQueries({ queryKey: ['tickets'] });
qc.invalidateQueries({ queryKey: ['ticket', ticket.displayId, 'audit'] });
},
});
}
export function useDeleteTicket() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/tickets/${id}`);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['tickets'] });
},
});
}
// ── Comments ─────────────────────────────────────────────────────────────────
export function useAddComment(ticketId: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: string) =>
(await api.post<Comment>(`/tickets/${ticketId}/comments`, { body })).data,
onSuccess: () => {
if (ticketId) {
qc.invalidateQueries({ queryKey: qk.ticket(ticketId) });
qc.invalidateQueries({ queryKey: qk.ticketAudit(ticketId) });
}
},
});
}
export function useDeleteComment(ticketId: string | undefined) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (commentId: string) => {
await api.delete(`/tickets/${ticketId}/comments/${commentId}`);
},
onSuccess: () => {
if (ticketId) {
qc.invalidateQueries({ queryKey: qk.ticket(ticketId) });
qc.invalidateQueries({ queryKey: qk.ticketAudit(ticketId) });
}
},
});
}
// ── CTI ──────────────────────────────────────────────────────────────────────
export function useCategories() {
return useQuery({
queryKey: qk.categories(),
queryFn: async () => (await api.get<Category[]>('/cti/categories')).data,
staleTime: 5 * 60 * 1000,
});
}
export function useTypes(categoryId?: string) {
return useQuery({
queryKey: qk.types(categoryId),
queryFn: async () =>
(await api.get<CTIType[]>('/cti/types', { params: categoryId ? { categoryId } : {} })).data,
enabled: !!categoryId,
staleTime: 5 * 60 * 1000,
});
}
export function useItems(typeId?: string) {
return useQuery({
queryKey: qk.items(typeId),
queryFn: async () =>
(await api.get<Item[]>('/cti/items', { params: typeId ? { typeId } : {} })).data,
enabled: !!typeId,
staleTime: 5 * 60 * 1000,
});
}
function useCtiMutation<TVars>(fn: (vars: TVars) => Promise<unknown>) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => qc.invalidateQueries({ queryKey: ['cti'] }),
});
}
export function useCreateCategory() {
return useCtiMutation(async (data: { name: string }) =>
(await api.post<Category>('/cti/categories', data)).data,
);
}
export function useUpdateCategory() {
return useCtiMutation(async ({ id, name }: { id: string; name: string }) =>
(await api.put<Category>(`/cti/categories/${id}`, { name })).data,
);
}
export function useDeleteCategory() {
return useCtiMutation(async (id: string) => {
await api.delete(`/cti/categories/${id}`);
});
}
export function useCreateType() {
return useCtiMutation(async (data: { name: string; categoryId: string }) =>
(await api.post<CTIType>('/cti/types', data)).data,
);
}
export function useUpdateType() {
return useCtiMutation(async ({ id, name }: { id: string; name: string }) =>
(await api.put<CTIType>(`/cti/types/${id}`, { name })).data,
);
}
export function useDeleteType() {
return useCtiMutation(async (id: string) => {
await api.delete(`/cti/types/${id}`);
});
}
export function useCreateItem() {
return useCtiMutation(async (data: { name: string; typeId: string }) =>
(await api.post<Item>('/cti/items', data)).data,
);
}
export function useUpdateItem() {
return useCtiMutation(async ({ id, name }: { id: string; name: string }) =>
(await api.put<Item>(`/cti/items/${id}`, { name })).data,
);
}
export function useDeleteItem() {
return useCtiMutation(async (id: string) => {
await api.delete(`/cti/items/${id}`);
});
}
// ── Users ────────────────────────────────────────────────────────────────────
export function useUsers() {
return useQuery({
queryKey: qk.users(),
queryFn: async () => (await api.get<User[]>('/users')).data,
staleTime: 60 * 1000,
});
}
export function useCreateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (data: Record<string, unknown>) =>
(await api.post<User>('/users', data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
}
export function useUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async ({ id, data }: { id: string; data: Record<string, unknown> }) =>
(await api.patch<User>(`/users/${id}`, data)).data,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
}
export function useDeleteUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (id: string) => {
await api.delete(`/users/${id}`);
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }),
});
}