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,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 { mode, panel, item } = nameModal;
|
||||
if (mode === 'add') {
|
||||
if (panel === 'category') {
|
||||
await api.post('/cti/categories', { name: nameValue.trim() });
|
||||
await fetchCategories();
|
||||
} else if (panel === 'type' && selectedCategory) {
|
||||
await api.post('/cti/types', { name: nameValue.trim(), categoryId: selectedCategory.id });
|
||||
await fetchTypes(selectedCategory.id);
|
||||
} else if (panel === 'item' && selectedType) {
|
||||
await api.post('/cti/items', { name: nameValue.trim(), typeId: selectedType.id });
|
||||
await fetchItems(selectedType.id);
|
||||
}
|
||||
} else {
|
||||
if (!item) return;
|
||||
if (panel === 'category') {
|
||||
await api.put(`/cti/categories/${item.id}`, { name: nameValue.trim() });
|
||||
await fetchCategories();
|
||||
if (selectedCategory?.id === item.id)
|
||||
setSelectedCategory((c) => (c ? { ...c, name: nameValue.trim() } : 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));
|
||||
} else if (panel === 'item') {
|
||||
await api.put(`/cti/items/${item.id}`, { name: nameValue.trim() });
|
||||
if (selectedType) await fetchItems(selectedType.id);
|
||||
}
|
||||
const name = nameValue.trim();
|
||||
if (!name) return;
|
||||
const { mode, panel, item } = nameModal;
|
||||
if (mode === 'add') {
|
||||
if (panel === 'category') {
|
||||
await createCategory.mutateAsync({ name });
|
||||
} else if (panel === 'type' && selectedCategory) {
|
||||
await createType.mutateAsync({ name, categoryId: selectedCategory.id });
|
||||
} else if (panel === 'item' && selectedType) {
|
||||
await createItem.mutateAsync({ name, typeId: selectedType.id });
|
||||
}
|
||||
} else if (item) {
|
||||
if (panel === 'category') {
|
||||
await updateCategory.mutateAsync({ id: item.id, name });
|
||||
if (selectedCategory?.id === item.id)
|
||||
setSelectedCategory((c) => (c ? { ...c, name } : c));
|
||||
} else if (panel === 'type') {
|
||||
await updateType.mutateAsync({ id: item.id, name });
|
||||
if (selectedType?.id === item.id) setSelectedType((t) => (t ? { ...t, name } : t));
|
||||
} else if (panel === 'item') {
|
||||
await updateItem.mutateAsync({ id: item.id, name });
|
||||
}
|
||||
closeModal();
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
closeModal();
|
||||
};
|
||||
|
||||
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');
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user