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:
@@ -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() }),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user