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:
+7
-6
@@ -1,12 +1,13 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
COPY client/package*.json ./client/
|
||||
RUN cd client && npm ci
|
||||
COPY client ./client
|
||||
COPY shared ./shared
|
||||
RUN cd client && npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/client/dist /usr/share/nginx/html
|
||||
COPY client/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
Generated
+1217
-3145
File diff suppressed because it is too large
Load Diff
@@ -13,8 +13,21 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"axios": "^1.7.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.4",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
@@ -25,6 +38,8 @@
|
||||
"react-router-dom": "^6.28.0",
|
||||
"remark-gfm": "^4.0.0",
|
||||
"sonner": "^1.7.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -32,6 +47,7 @@
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
|
||||
@@ -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() }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
staleTime: 30 * 1000,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import api from '../api/client';
|
||||
import { Category, CTIType, Item } from '../types';
|
||||
import { useCategories, useItems, useTypes } from '../api/queries';
|
||||
|
||||
interface CTISelectProps {
|
||||
value: { categoryId: string; typeId: string; itemId: string };
|
||||
@@ -9,34 +7,9 @@ interface CTISelectProps {
|
||||
}
|
||||
|
||||
export default function CTISelect({ value, onChange, disabled }: CTISelectProps) {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [types, setTypes] = useState<CTIType[]>([]);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.categoryId) {
|
||||
setTypes([]);
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<CTIType[]>('/cti/types', { params: { categoryId: value.categoryId } })
|
||||
.then((r) => setTypes(r.data));
|
||||
}, [value.categoryId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!value.typeId) {
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
api
|
||||
.get<Item[]>('/cti/items', { params: { typeId: value.typeId } })
|
||||
.then((r) => setItems(r.data));
|
||||
}, [value.typeId]);
|
||||
const { data: categories = [] } = useCategories();
|
||||
const { data: types = [] } = useTypes(value.categoryId || undefined);
|
||||
const { data: items = [] } = useItems(value.typeId || undefined);
|
||||
|
||||
const handleCategory = (categoryId: string) => {
|
||||
onChange({ categoryId, typeId: '', itemId: '' });
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import SeverityBadge from './SeverityBadge';
|
||||
|
||||
describe('SeverityBadge', () => {
|
||||
it('renders the severity label', () => {
|
||||
render(<SeverityBadge severity={1} />);
|
||||
expect(screen.getByText('SEV 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('falls back to SEV 5 styling for out-of-range values', () => {
|
||||
render(<SeverityBadge severity={99 as number} />);
|
||||
expect(screen.getByText('SEV 5')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
|
||||
describe('StatusBadge', () => {
|
||||
it.each([
|
||||
['OPEN', 'Open'],
|
||||
['IN_PROGRESS', 'In Progress'],
|
||||
['RESOLVED', 'Resolved'],
|
||||
['CLOSED', 'Closed'],
|
||||
] as const)('renders %s as "%s"', (status, label) => {
|
||||
render(<StatusBadge status={status} />);
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { queryClient } from './api/queryClient';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Search, ChevronRight, X } from 'lucide-react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import api from '../api/client';
|
||||
import Layout from '../components/Layout';
|
||||
import SeverityBadge from '../components/SeverityBadge';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import Avatar from '../components/Avatar';
|
||||
import { Ticket, TicketStatus, Category, CTIType, Item } from '../types';
|
||||
import { TicketStatus, Category, CTIType, Item } from '../types';
|
||||
import { useTickets, useCategories, useTypes, useItems } from '../api/queries';
|
||||
|
||||
const STATUSES: { value: TicketStatus | ''; label: string }[] = [
|
||||
{ value: '', label: 'All Statuses' },
|
||||
@@ -20,7 +20,6 @@ const STATUSES: { value: TicketStatus | ''; label: string }[] = [
|
||||
const selectClass =
|
||||
'bg-gray-800 border border-gray-700 text-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent';
|
||||
|
||||
// Queue label built from whatever CTI level is selected
|
||||
function queueLabel(category: Category | null, type: CTIType | null, item: Item | null): string {
|
||||
if (item && type && category) return `${category.name} › ${type.name} › ${item.name}`;
|
||||
if (type && category) return `${category.name} › ${type.name}`;
|
||||
@@ -29,41 +28,44 @@ function queueLabel(category: Category | null, type: CTIType | null, item: Item
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [status, setStatus] = useState<TicketStatus | ''>('');
|
||||
const [severity, setSeverity] = useState('');
|
||||
|
||||
// CTI queue filter state
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [types, setTypes] = useState<CTIType[]>([]);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<CTIType | null>(null);
|
||||
const [selectedItem, setSelectedItem] = useState<Item | null>(null);
|
||||
const [showQueueFilter, setShowQueueFilter] = useState(false);
|
||||
|
||||
const { data: categories = [] } = useCategories();
|
||||
const { data: types = [] } = useTypes(selectedCategory?.id);
|
||||
const { data: items = [] } = useItems(selectedType?.id);
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data));
|
||||
}, []);
|
||||
const t = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [search]);
|
||||
|
||||
const ticketParams: Record<string, string> = {};
|
||||
if (selectedItem) ticketParams.itemId = selectedItem.id;
|
||||
else if (selectedType) ticketParams.typeId = selectedType.id;
|
||||
else if (selectedCategory) ticketParams.categoryId = selectedCategory.id;
|
||||
if (status) ticketParams.status = status;
|
||||
if (severity) ticketParams.severity = severity;
|
||||
if (debouncedSearch) ticketParams.search = debouncedSearch;
|
||||
|
||||
const { data: tickets = [], isLoading } = useTickets(ticketParams);
|
||||
|
||||
const handleCategorySelect = (cat: Category) => {
|
||||
setSelectedCategory(cat);
|
||||
setSelectedType(null);
|
||||
setSelectedItem(null);
|
||||
setTypes([]);
|
||||
setItems([]);
|
||||
api
|
||||
.get<CTIType[]>('/cti/types', { params: { categoryId: cat.id } })
|
||||
.then((r) => setTypes(r.data));
|
||||
};
|
||||
|
||||
const handleTypeSelect = (type: CTIType) => {
|
||||
setSelectedType(type);
|
||||
setSelectedItem(null);
|
||||
setItems([]);
|
||||
api.get<Item[]>('/cti/items', { params: { typeId: type.id } }).then((r) => setItems(r.data));
|
||||
};
|
||||
|
||||
const handleItemSelect = (item: Item) => {
|
||||
@@ -75,34 +77,8 @@ export default function Dashboard() {
|
||||
setSelectedCategory(null);
|
||||
setSelectedType(null);
|
||||
setSelectedItem(null);
|
||||
setTypes([]);
|
||||
setItems([]);
|
||||
};
|
||||
|
||||
// Derive the most specific filter param
|
||||
const queueParams: Record<string, string> = {};
|
||||
if (selectedItem) queueParams.itemId = selectedItem.id;
|
||||
else if (selectedType) queueParams.typeId = selectedType.id;
|
||||
else if (selectedCategory) queueParams.categoryId = selectedCategory.id;
|
||||
|
||||
const fetchTickets = useCallback(() => {
|
||||
setLoading(true);
|
||||
const params: Record<string, string> = { ...queueParams };
|
||||
if (status) params.status = status;
|
||||
if (severity) params.severity = severity;
|
||||
if (search) params.search = search;
|
||||
api
|
||||
.get<Ticket[]>('/tickets', { params })
|
||||
.then((r) => setTickets(r.data))
|
||||
.finally(() => setLoading(false));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [status, severity, search, selectedCategory, selectedType, selectedItem]);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(fetchTickets, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [fetchTickets]);
|
||||
|
||||
const activeQueue = queueLabel(selectedCategory, selectedType, selectedItem);
|
||||
|
||||
return (
|
||||
@@ -263,7 +239,7 @@ export default function Dashboard() {
|
||||
</div>
|
||||
|
||||
{/* Ticket list */}
|
||||
{loading ? (
|
||||
{isLoading ? (
|
||||
<div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
|
||||
) : tickets.length === 0 ? (
|
||||
<div className="text-center py-16 text-gray-600 text-sm">No tickets found</div>
|
||||
|
||||
@@ -1,36 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import api from '../api/client';
|
||||
import Layout from '../components/Layout';
|
||||
import SeverityBadge from '../components/SeverityBadge';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import { Ticket } from '../types';
|
||||
import { useTickets } from '../api/queries';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
export default function MyTickets() {
|
||||
const { user } = useAuth();
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const openQ = useTickets(user ? { assigneeId: user.id, status: 'OPEN' } : {});
|
||||
const inProgressQ = useTickets(user ? { assigneeId: user.id, status: 'IN_PROGRESS' } : {});
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
// Only show active tickets — OPEN and IN_PROGRESS
|
||||
Promise.all([
|
||||
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'OPEN' } }),
|
||||
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'IN_PROGRESS' } }),
|
||||
])
|
||||
.then(([openRes, inProgressRes]) => {
|
||||
const combined = [...openRes.data, ...inProgressRes.data];
|
||||
const loading = openQ.isLoading || inProgressQ.isLoading;
|
||||
|
||||
const tickets = useMemo(() => {
|
||||
if (!user) return [];
|
||||
const combined = [...(openQ.data ?? []), ...(inProgressQ.data ?? [])];
|
||||
combined.sort(
|
||||
(a, b) =>
|
||||
a.severity - b.severity ||
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
setTickets(combined);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [user]);
|
||||
return combined;
|
||||
}, [user, openQ.data, inProgressQ.data]);
|
||||
|
||||
return (
|
||||
<Layout title="My Tickets">
|
||||
|
||||
@@ -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));
|
||||
await addComment.mutateAsync(commentBody.trim());
|
||||
setCommentBody('');
|
||||
setPreview(false);
|
||||
} finally {
|
||||
setSubmittingComment(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} />
|
||||
|
||||
@@ -1,66 +1,81 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, ChevronRight } from 'lucide-react';
|
||||
import api from '../../api/client';
|
||||
import Layout from '../../components/Layout';
|
||||
import Modal from '../../components/Modal';
|
||||
import { Category, CTIType, Item } from '../../types';
|
||||
import { Category, CTIType } from '../../types';
|
||||
import {
|
||||
useCategories,
|
||||
useTypes,
|
||||
useItems,
|
||||
useCreateCategory,
|
||||
useUpdateCategory,
|
||||
useDeleteCategory,
|
||||
useCreateType,
|
||||
useUpdateType,
|
||||
useDeleteType,
|
||||
useCreateItem,
|
||||
useUpdateItem,
|
||||
useDeleteItem,
|
||||
} from '../../api/queries';
|
||||
|
||||
type PanelItem = { id: string; name: string };
|
||||
type Panel = 'category' | 'type' | 'item';
|
||||
|
||||
interface NameModalState {
|
||||
open: boolean;
|
||||
mode: 'add' | 'edit';
|
||||
panel: 'category' | 'type' | 'item';
|
||||
panel: Panel;
|
||||
item?: PanelItem;
|
||||
}
|
||||
|
||||
export default function AdminCTI() {
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [types, setTypes] = useState<CTIType[]>([]);
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
|
||||
const [selectedType, setSelectedType] = useState<CTIType | null>(null);
|
||||
|
||||
const { data: categories = [] } = useCategories();
|
||||
const { data: types = [] } = useTypes(selectedCategory?.id);
|
||||
const { data: items = [] } = useItems(selectedType?.id);
|
||||
|
||||
const createCategory = useCreateCategory();
|
||||
const updateCategory = useUpdateCategory();
|
||||
const deleteCategory = useDeleteCategory();
|
||||
const createType = useCreateType();
|
||||
const updateType = useUpdateType();
|
||||
const deleteType = useDeleteType();
|
||||
const createItem = useCreateItem();
|
||||
const updateItem = useUpdateItem();
|
||||
const deleteItem = useDeleteItem();
|
||||
|
||||
const [nameModal, setNameModal] = useState<NameModalState>({
|
||||
open: false,
|
||||
mode: 'add',
|
||||
panel: 'category',
|
||||
});
|
||||
const [nameValue, setNameValue] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const fetchCategories = () =>
|
||||
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data));
|
||||
|
||||
const fetchTypes = (categoryId: string) =>
|
||||
api.get<CTIType[]>('/cti/types', { params: { categoryId } }).then((r) => setTypes(r.data));
|
||||
|
||||
const fetchItems = (typeId: string) =>
|
||||
api.get<Item[]>('/cti/items', { params: { typeId } }).then((r) => setItems(r.data));
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, []);
|
||||
const submitting =
|
||||
createCategory.isPending ||
|
||||
updateCategory.isPending ||
|
||||
createType.isPending ||
|
||||
updateType.isPending ||
|
||||
createItem.isPending ||
|
||||
updateItem.isPending;
|
||||
|
||||
const selectCategory = (cat: Category) => {
|
||||
setSelectedCategory(cat);
|
||||
setSelectedType(null);
|
||||
setItems([]);
|
||||
fetchTypes(cat.id);
|
||||
};
|
||||
|
||||
const selectType = (type: CTIType) => {
|
||||
setSelectedType(type);
|
||||
fetchItems(type.id);
|
||||
};
|
||||
|
||||
const openAdd = (panel: 'category' | 'type' | 'item') => {
|
||||
const openAdd = (panel: Panel) => {
|
||||
setNameValue('');
|
||||
setNameModal({ open: true, mode: 'add', panel });
|
||||
};
|
||||
|
||||
const openEdit = (panel: 'category' | 'type' | 'item', item: PanelItem) => {
|
||||
const openEdit = (panel: Panel, item: PanelItem) => {
|
||||
setNameValue(item.name);
|
||||
setNameModal({ open: true, mode: 'edit', panel, item });
|
||||
};
|
||||
@@ -69,65 +84,45 @@ export default function AdminCTI() {
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!nameValue.trim()) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const name = nameValue.trim();
|
||||
if (!name) return;
|
||||
const { mode, panel, item } = nameModal;
|
||||
if (mode === 'add') {
|
||||
if (panel === 'category') {
|
||||
await api.post('/cti/categories', { name: nameValue.trim() });
|
||||
await fetchCategories();
|
||||
await createCategory.mutateAsync({ name });
|
||||
} else if (panel === 'type' && selectedCategory) {
|
||||
await api.post('/cti/types', { name: nameValue.trim(), categoryId: selectedCategory.id });
|
||||
await fetchTypes(selectedCategory.id);
|
||||
await createType.mutateAsync({ name, categoryId: selectedCategory.id });
|
||||
} else if (panel === 'item' && selectedType) {
|
||||
await api.post('/cti/items', { name: nameValue.trim(), typeId: selectedType.id });
|
||||
await fetchItems(selectedType.id);
|
||||
await createItem.mutateAsync({ name, typeId: selectedType.id });
|
||||
}
|
||||
} else {
|
||||
if (!item) return;
|
||||
} else if (item) {
|
||||
if (panel === 'category') {
|
||||
await api.put(`/cti/categories/${item.id}`, { name: nameValue.trim() });
|
||||
await fetchCategories();
|
||||
await updateCategory.mutateAsync({ id: item.id, name });
|
||||
if (selectedCategory?.id === item.id)
|
||||
setSelectedCategory((c) => (c ? { ...c, name: nameValue.trim() } : c));
|
||||
setSelectedCategory((c) => (c ? { ...c, name } : c));
|
||||
} else if (panel === 'type') {
|
||||
await api.put(`/cti/types/${item.id}`, { name: nameValue.trim() });
|
||||
if (selectedCategory) await fetchTypes(selectedCategory.id);
|
||||
if (selectedType?.id === item.id)
|
||||
setSelectedType((t) => (t ? { ...t, name: nameValue.trim() } : t));
|
||||
await updateType.mutateAsync({ id: item.id, name });
|
||||
if (selectedType?.id === item.id) setSelectedType((t) => (t ? { ...t, name } : t));
|
||||
} else if (panel === 'item') {
|
||||
await api.put(`/cti/items/${item.id}`, { name: nameValue.trim() });
|
||||
if (selectedType) await fetchItems(selectedType.id);
|
||||
await updateItem.mutateAsync({ id: item.id, name });
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (panel: 'category' | 'type' | 'item', item: PanelItem) => {
|
||||
const handleDelete = async (panel: Panel, item: PanelItem) => {
|
||||
if (!confirm(`Delete "${item.name}"? This will also delete all child records.`)) return;
|
||||
if (panel === 'category') {
|
||||
await api.delete(`/cti/categories/${item.id}`);
|
||||
await deleteCategory.mutateAsync(item.id);
|
||||
if (selectedCategory?.id === item.id) {
|
||||
setSelectedCategory(null);
|
||||
setSelectedType(null);
|
||||
setTypes([]);
|
||||
setItems([]);
|
||||
}
|
||||
await fetchCategories();
|
||||
} else if (panel === 'type') {
|
||||
await api.delete(`/cti/types/${item.id}`);
|
||||
if (selectedType?.id === item.id) {
|
||||
setSelectedType(null);
|
||||
setItems([]);
|
||||
}
|
||||
if (selectedCategory) await fetchTypes(selectedCategory.id);
|
||||
await deleteType.mutateAsync(item.id);
|
||||
if (selectedType?.id === item.id) setSelectedType(null);
|
||||
} else {
|
||||
await api.delete(`/cti/items/${item.id}`);
|
||||
if (selectedType) await fetchItems(selectedType.id);
|
||||
await deleteItem.mutateAsync(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react';
|
||||
import api from '../../api/client';
|
||||
import Layout from '../../components/Layout';
|
||||
import Modal from '../../components/Modal';
|
||||
import { User, Role } from '../../types';
|
||||
import { useAuth } from '../../contexts/AuthContext';
|
||||
import {
|
||||
useUsers,
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
} from '../../api/queries';
|
||||
|
||||
interface UserForm {
|
||||
username: string;
|
||||
@@ -45,22 +50,19 @@ const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||
|
||||
export default function AdminUsers() {
|
||||
const { user: authUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const { data: users = [] } = useUsers();
|
||||
const createUser = useCreateUser();
|
||||
const updateUser = useUpdateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
const [modal, setModal] = useState<'add' | 'edit' | null>(null);
|
||||
const [selected, setSelected] = useState<User | null>(null);
|
||||
const [form, setForm] = useState<UserForm>(BLANK_FORM);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const [newApiKey, setNewApiKey] = useState<string | null>(null);
|
||||
|
||||
const fetchUsers = () => {
|
||||
api.get<User[]>('/users').then((r) => setUsers(r.data));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
const submitting = createUser.isPending || updateUser.isPending;
|
||||
|
||||
const openAdd = () => {
|
||||
setForm(BLANK_FORM);
|
||||
@@ -87,12 +89,10 @@ export default function AdminUsers() {
|
||||
setModal(null);
|
||||
setSelected(null);
|
||||
setNewApiKey(null);
|
||||
fetchUsers();
|
||||
};
|
||||
|
||||
const handleAdd = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload: Record<string, string> = {
|
||||
@@ -102,21 +102,18 @@ export default function AdminUsers() {
|
||||
role: form.role,
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
const res = await api.post<User>('/users', payload);
|
||||
if (res.data.apiKey) setNewApiKey(res.data.apiKey);
|
||||
const created = await createUser.mutateAsync(payload);
|
||||
if (created.apiKey) setNewApiKey(created.apiKey);
|
||||
else closeModal();
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { error?: string } } };
|
||||
setError(e.response?.data?.error ?? 'Failed to create user');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selected) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload: Record<string, string> = {
|
||||
@@ -125,20 +122,17 @@ export default function AdminUsers() {
|
||||
role: form.role,
|
||||
};
|
||||
if (form.password) payload.password = form.password;
|
||||
await api.patch(`/users/${selected.id}`, payload);
|
||||
await updateUser.mutateAsync({ id: selected.id, data: payload });
|
||||
closeModal();
|
||||
} catch (err: unknown) {
|
||||
const e = err as { response?: { data?: { error?: string } } };
|
||||
setError(e.response?.data?.error ?? 'Failed to update user');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (u: User) => {
|
||||
if (!confirm(`Delete user "${u.displayName}"?`)) return;
|
||||
await api.delete(`/users/${u.id}`);
|
||||
fetchUsers();
|
||||
await deleteUser.mutateAsync(u.id);
|
||||
};
|
||||
|
||||
const handleRegenerateKey = async (u: User) => {
|
||||
@@ -148,8 +142,11 @@ export default function AdminUsers() {
|
||||
)
|
||||
)
|
||||
return;
|
||||
const res = await api.patch<User>(`/users/${u.id}`, { regenerateApiKey: true });
|
||||
setNewApiKey(res.data.apiKey ?? null);
|
||||
const updated = await updateUser.mutateAsync({
|
||||
id: u.id,
|
||||
data: { regenerateApiKey: true },
|
||||
});
|
||||
setNewApiKey(updated.apiKey ?? null);
|
||||
setSelected(u);
|
||||
setModal('edit');
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { afterEach } from 'vitest';
|
||||
import { cleanup } from '@testing-library/react';
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
include: ['src/**/*.{test,spec}.{ts,tsx}'],
|
||||
css: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user