Refactor and centralize user and pagination interfaces in useAdmin hook
- Unified `User` and `PaginationMeta` type definitions into `useAdmin` to improve maintainability and consistency. - Updated affected components (`UserManagementContent`, `UserListTable`, `UserFormDialog`, `UserActionMenu`) to reference the centralized types. - Enhanced test coverage for user-related hooks to include create, update, delete, activate, deactivate, and bulk actions.
This commit is contained in:
@@ -30,17 +30,9 @@ import {
|
||||
useActivateUser,
|
||||
useDeactivateUser,
|
||||
useDeleteUser,
|
||||
type User,
|
||||
} from '@/lib/api/hooks/useAdmin';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string | null;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
}
|
||||
|
||||
interface UserActionMenuProps {
|
||||
user: User;
|
||||
isCurrentUser: boolean;
|
||||
|
||||
@@ -26,6 +26,7 @@ import { toast } from 'sonner';
|
||||
import {
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
type User,
|
||||
} from '@/lib/api/hooks/useAdmin';
|
||||
|
||||
// ============================================================================
|
||||
@@ -58,15 +59,6 @@ type UserFormData = z.infer<typeof userFormSchema>;
|
||||
// Component
|
||||
// ============================================================================
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string | null;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
}
|
||||
|
||||
interface UserFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
|
||||
@@ -29,25 +29,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { UserActionMenu } from './UserActionMenu';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string | null;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
import type { User, PaginationMeta } from '@/lib/api/hooks/useAdmin';
|
||||
|
||||
interface UserListTableProps {
|
||||
users: User[];
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import { useAdminUsers } from '@/lib/api/hooks/useAdmin';
|
||||
import { useAdminUsers, type User, type PaginationMeta } from '@/lib/api/hooks/useAdmin';
|
||||
import { UserListTable } from './UserListTable';
|
||||
import { UserFormDialog } from './UserFormDialog';
|
||||
import { BulkActionToolbar } from './BulkActionToolbar';
|
||||
@@ -30,13 +30,13 @@ export function UserManagementContent() {
|
||||
const [selectedUsers, setSelectedUsers] = useState<string[]>([]);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<'create' | 'edit'>('create');
|
||||
const [editingUser, setEditingUser] = useState<any | null>(null);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
|
||||
// Fetch users with query params
|
||||
const { data, isLoading } = useAdminUsers(page, 20);
|
||||
|
||||
const users = data?.data || [];
|
||||
const pagination = data?.pagination || {
|
||||
const users: User[] = data?.data || [];
|
||||
const pagination: PaginationMeta = data?.pagination || {
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
@@ -107,7 +107,7 @@ export function UserManagementContent() {
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleEditUser = (user: any) => {
|
||||
const handleEditUser = (user: User) => {
|
||||
setDialogMode('edit');
|
||||
setEditingUser(user);
|
||||
setDialogOpen(true);
|
||||
|
||||
@@ -123,6 +123,39 @@ export function useAdminStats() {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pagination metadata structure
|
||||
*/
|
||||
export interface PaginationMeta {
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* User interface matching backend UserResponse
|
||||
*/
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string;
|
||||
last_name: string | null;
|
||||
is_active: boolean;
|
||||
is_superuser: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated user list response
|
||||
*/
|
||||
export interface PaginatedUserResponse {
|
||||
data: User[];
|
||||
pagination: PaginationMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to fetch paginated list of all users (for admin)
|
||||
*
|
||||
@@ -135,7 +168,7 @@ export function useAdminUsers(page = 1, limit = DEFAULT_PAGE_LIMIT) {
|
||||
|
||||
return useQuery({
|
||||
queryKey: ['admin', 'users', page, limit],
|
||||
queryFn: async () => {
|
||||
queryFn: async (): Promise<PaginatedUserResponse> => {
|
||||
const response = await adminListUsers({
|
||||
query: { page, limit },
|
||||
throwOnError: false,
|
||||
@@ -146,7 +179,7 @@ export function useAdminUsers(page = 1, limit = DEFAULT_PAGE_LIMIT) {
|
||||
}
|
||||
|
||||
// Type assertion: if no error, response has data
|
||||
return (response as { data: unknown }).data;
|
||||
return (response as { data: PaginatedUserResponse }).data;
|
||||
},
|
||||
// Only fetch if user is a superuser (frontend guard)
|
||||
enabled: user?.is_superuser === true,
|
||||
|
||||
@@ -3,10 +3,29 @@
|
||||
* Verifies admin statistics and list fetching functionality
|
||||
*/
|
||||
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useAdminStats, useAdminUsers, useAdminOrganizations } from '@/lib/api/hooks/useAdmin';
|
||||
import { adminListUsers, adminListOrganizations } from '@/lib/api/client';
|
||||
import {
|
||||
useAdminStats,
|
||||
useAdminUsers,
|
||||
useAdminOrganizations,
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
useActivateUser,
|
||||
useDeactivateUser,
|
||||
useBulkUserAction,
|
||||
} from '@/lib/api/hooks/useAdmin';
|
||||
import {
|
||||
adminListUsers,
|
||||
adminListOrganizations,
|
||||
adminCreateUser,
|
||||
adminUpdateUser,
|
||||
adminDeleteUser,
|
||||
adminActivateUser,
|
||||
adminDeactivateUser,
|
||||
adminBulkUserAction,
|
||||
} from '@/lib/api/client';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
|
||||
// Mock dependencies
|
||||
@@ -79,12 +98,12 @@ describe('useAdmin hooks', () => {
|
||||
});
|
||||
|
||||
expect(mockAdminListUsers).toHaveBeenCalledWith({
|
||||
query: { page: 1, limit: 10000 },
|
||||
query: { page: 1, limit: 100 },
|
||||
throwOnError: false,
|
||||
});
|
||||
|
||||
expect(mockAdminListOrganizations).toHaveBeenCalledWith({
|
||||
query: { page: 1, limit: 10000 },
|
||||
query: { page: 1, limit: 100 },
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
@@ -327,4 +346,253 @@ describe('useAdmin hooks', () => {
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateUser', () => {
|
||||
it('creates a user successfully', async () => {
|
||||
const mockCreateUser = adminCreateUser as jest.MockedFunction<typeof adminCreateUser>;
|
||||
mockCreateUser.mockResolvedValue({
|
||||
data: { id: '1', email: 'newuser@example.com', first_name: 'New', last_name: 'User', is_active: true, is_superuser: false, created_at: '2025-01-01T00:00:00Z' },
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useCreateUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
email: 'newuser@example.com',
|
||||
first_name: 'New',
|
||||
last_name: 'User',
|
||||
password: 'Password123',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockCreateUser).toHaveBeenCalledWith({
|
||||
body: {
|
||||
email: 'newuser@example.com',
|
||||
first_name: 'New',
|
||||
last_name: 'User',
|
||||
password: 'Password123',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
},
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles create error', async () => {
|
||||
const mockCreateUser = adminCreateUser as jest.MockedFunction<typeof adminCreateUser>;
|
||||
mockCreateUser.mockResolvedValue({ error: 'Create failed' } as any);
|
||||
|
||||
const { result } = renderHook(() => useCreateUser(), { wrapper });
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({
|
||||
email: 'test@example.com',
|
||||
first_name: 'Test',
|
||||
password: 'Password123',
|
||||
is_active: true,
|
||||
is_superuser: false,
|
||||
})
|
||||
).rejects.toThrow('Failed to create user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateUser', () => {
|
||||
it('updates a user successfully', async () => {
|
||||
const mockUpdateUser = adminUpdateUser as jest.MockedFunction<typeof adminUpdateUser>;
|
||||
mockUpdateUser.mockResolvedValue({
|
||||
data: { id: '1', email: 'updated@example.com', first_name: 'Updated', last_name: 'User', is_active: true, is_superuser: false, created_at: '2025-01-01T00:00:00Z' },
|
||||
} as any);
|
||||
|
||||
const { result } = renderHook(() => useUpdateUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
userId: '1',
|
||||
userData: {
|
||||
email: 'updated@example.com',
|
||||
first_name: 'Updated',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockUpdateUser).toHaveBeenCalledWith({
|
||||
path: { user_id: '1' },
|
||||
body: {
|
||||
email: 'updated@example.com',
|
||||
first_name: 'Updated',
|
||||
},
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles update error', async () => {
|
||||
const mockUpdateUser = adminUpdateUser as jest.MockedFunction<typeof adminUpdateUser>;
|
||||
mockUpdateUser.mockResolvedValue({ error: 'Update failed' } as any);
|
||||
|
||||
const { result } = renderHook(() => useUpdateUser(), { wrapper });
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({
|
||||
userId: '1',
|
||||
userData: { email: 'test@example.com' },
|
||||
})
|
||||
).rejects.toThrow('Failed to update user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteUser', () => {
|
||||
it('deletes a user successfully', async () => {
|
||||
const mockDeleteUser = adminDeleteUser as jest.MockedFunction<typeof adminDeleteUser>;
|
||||
mockDeleteUser.mockResolvedValue({ data: { success: true } } as any);
|
||||
|
||||
const { result } = renderHook(() => useDeleteUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('1');
|
||||
});
|
||||
|
||||
expect(mockDeleteUser).toHaveBeenCalledWith({
|
||||
path: { user_id: '1' },
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles delete error', async () => {
|
||||
const mockDeleteUser = adminDeleteUser as jest.MockedFunction<typeof adminDeleteUser>;
|
||||
mockDeleteUser.mockResolvedValue({ error: 'Delete failed' } as any);
|
||||
|
||||
const { result } = renderHook(() => useDeleteUser(), { wrapper });
|
||||
|
||||
await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to delete user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useActivateUser', () => {
|
||||
it('activates a user successfully', async () => {
|
||||
const mockActivateUser = adminActivateUser as jest.MockedFunction<typeof adminActivateUser>;
|
||||
mockActivateUser.mockResolvedValue({ data: { success: true } } as any);
|
||||
|
||||
const { result } = renderHook(() => useActivateUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('1');
|
||||
});
|
||||
|
||||
expect(mockActivateUser).toHaveBeenCalledWith({
|
||||
path: { user_id: '1' },
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles activate error', async () => {
|
||||
const mockActivateUser = adminActivateUser as jest.MockedFunction<typeof adminActivateUser>;
|
||||
mockActivateUser.mockResolvedValue({ error: 'Activate failed' } as any);
|
||||
|
||||
const { result } = renderHook(() => useActivateUser(), { wrapper });
|
||||
|
||||
await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to activate user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeactivateUser', () => {
|
||||
it('deactivates a user successfully', async () => {
|
||||
const mockDeactivateUser = adminDeactivateUser as jest.MockedFunction<typeof adminDeactivateUser>;
|
||||
mockDeactivateUser.mockResolvedValue({ data: { success: true } } as any);
|
||||
|
||||
const { result } = renderHook(() => useDeactivateUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync('1');
|
||||
});
|
||||
|
||||
expect(mockDeactivateUser).toHaveBeenCalledWith({
|
||||
path: { user_id: '1' },
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles deactivate error', async () => {
|
||||
const mockDeactivateUser = adminDeactivateUser as jest.MockedFunction<typeof adminDeactivateUser>;
|
||||
mockDeactivateUser.mockResolvedValue({ error: 'Deactivate failed' } as any);
|
||||
|
||||
const { result } = renderHook(() => useDeactivateUser(), { wrapper });
|
||||
|
||||
await expect(result.current.mutateAsync('1')).rejects.toThrow('Failed to deactivate user');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useBulkUserAction', () => {
|
||||
it('performs bulk activate successfully', async () => {
|
||||
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
|
||||
mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 2 } } as any);
|
||||
|
||||
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
action: 'activate',
|
||||
userIds: ['1', '2'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockBulkAction).toHaveBeenCalledWith({
|
||||
body: { action: 'activate', user_ids: ['1', '2'] },
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('performs bulk deactivate successfully', async () => {
|
||||
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
|
||||
mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 3 } } as any);
|
||||
|
||||
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
action: 'deactivate',
|
||||
userIds: ['1', '2', '3'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockBulkAction).toHaveBeenCalledWith({
|
||||
body: { action: 'deactivate', user_ids: ['1', '2', '3'] },
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('performs bulk delete successfully', async () => {
|
||||
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
|
||||
mockBulkAction.mockResolvedValue({ data: { success: true, affected_count: 1 } } as any);
|
||||
|
||||
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
action: 'delete',
|
||||
userIds: ['1'],
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockBulkAction).toHaveBeenCalledWith({
|
||||
body: { action: 'delete', user_ids: ['1'] },
|
||||
throwOnError: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles bulk action error', async () => {
|
||||
const mockBulkAction = adminBulkUserAction as jest.MockedFunction<typeof adminBulkUserAction>;
|
||||
mockBulkAction.mockResolvedValue({ error: 'Bulk action failed' } as any);
|
||||
|
||||
const { result } = renderHook(() => useBulkUserAction(), { wrapper });
|
||||
|
||||
await expect(
|
||||
result.current.mutateAsync({
|
||||
action: 'activate',
|
||||
userIds: ['1', '2'],
|
||||
})
|
||||
).rejects.toThrow('Failed to perform bulk action');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user