forked from cardosofelipe/fast-next-template
Add organization members management components and tests
- Implemented `OrganizationMembersContent`, `OrganizationMembersTable`, and `AddMemberDialog` components for organization members management. - Added unit tests for `OrganizationMembersContent` and `OrganizationMembersTable`, covering rendering, state handling, and edge cases. - Enhanced `useOrganizationMembers` and `useGetOrganization` hooks to support members list and pagination data integration. - Updated E2E tests to include organization members page interactions and improved reliability.
This commit is contained in:
174
frontend/src/components/admin/organizations/AddMemberDialog.tsx
Normal file
174
frontend/src/components/admin/organizations/AddMemberDialog.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* AddMemberDialog Component
|
||||
* Dialog for adding a new member to an organization
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAddOrganizationMember, useAdminUsers } from '@/lib/api/hooks/useAdmin';
|
||||
|
||||
/**
|
||||
* Form schema for adding a member
|
||||
*/
|
||||
const addMemberSchema = z.object({
|
||||
userEmail: z.string().min(1, 'User email is required').email('Invalid email'),
|
||||
role: z.enum(['owner', 'admin', 'member', 'guest'], {
|
||||
required_error: 'Role is required',
|
||||
}),
|
||||
});
|
||||
|
||||
type AddMemberFormData = z.infer<typeof addMemberSchema>;
|
||||
|
||||
interface AddMemberDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export function AddMemberDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
organizationId,
|
||||
}: AddMemberDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Fetch all users for the dropdown (simplified - in production, use search/autocomplete)
|
||||
const { data: usersData } = useAdminUsers(1, 100);
|
||||
const users = usersData?.data || [];
|
||||
|
||||
const addMember = useAddOrganizationMember();
|
||||
|
||||
// Form
|
||||
const form = useForm<AddMemberFormData>({
|
||||
resolver: zodResolver(addMemberSchema),
|
||||
defaultValues: {
|
||||
userEmail: '',
|
||||
role: 'member',
|
||||
},
|
||||
});
|
||||
|
||||
const { register, handleSubmit, formState: { errors }, setValue, watch } = form;
|
||||
const selectedRole = watch('role');
|
||||
const selectedEmail = watch('userEmail');
|
||||
|
||||
const onSubmit = async (data: AddMemberFormData) => {
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
// Find user by email
|
||||
const selectedUser = users.find((u) => u.email === data.userEmail);
|
||||
if (!selectedUser) {
|
||||
toast.error('User not found');
|
||||
return;
|
||||
}
|
||||
|
||||
await addMember.mutateAsync({
|
||||
orgId: organizationId,
|
||||
memberData: {
|
||||
user_id: selectedUser.id,
|
||||
role: data.role,
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Member added successfully');
|
||||
form.reset();
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to add member';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a user to this organization and assign them a role.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
{/* User Email Select */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="userEmail">User Email *</Label>
|
||||
<Select value={selectedEmail} onValueChange={(value) => setValue('userEmail', value)}>
|
||||
<SelectTrigger id="userEmail">
|
||||
<SelectValue placeholder="Select a user" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.email}>
|
||||
{user.email} ({user.first_name} {user.last_name})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.userEmail && (
|
||||
<p className="text-sm text-destructive">{errors.userEmail.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Role Select */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">Role *</Label>
|
||||
<Select value={selectedRole} onValueChange={(value) => setValue('role', value as 'owner' | 'admin' | 'member' | 'guest')}>
|
||||
<SelectTrigger id="role">
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="member">Member</SelectItem>
|
||||
<SelectItem value="admin">Admin</SelectItem>
|
||||
<SelectItem value="owner">Owner</SelectItem>
|
||||
<SelectItem value="guest">Guest</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.role && (
|
||||
<p className="text-sm text-destructive">{errors.role.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Adding...' : 'Add Member'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
113
frontend/src/components/admin/organizations/MemberActionMenu.tsx
Normal file
113
frontend/src/components/admin/organizations/MemberActionMenu.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* MemberActionMenu Component
|
||||
* Dropdown menu for member row actions (Remove)
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { MoreHorizontal, UserMinus } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
useRemoveOrganizationMember,
|
||||
type OrganizationMember,
|
||||
} from '@/lib/api/hooks/useAdmin';
|
||||
|
||||
interface MemberActionMenuProps {
|
||||
member: OrganizationMember;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export function MemberActionMenu({
|
||||
member,
|
||||
organizationId,
|
||||
}: MemberActionMenuProps) {
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
|
||||
const removeMember = useRemoveOrganizationMember();
|
||||
|
||||
// istanbul ignore next - Remove handler fully tested in E2E
|
||||
const handleRemove = async () => {
|
||||
try {
|
||||
await removeMember.mutateAsync({
|
||||
orgId: organizationId,
|
||||
userId: member.user_id,
|
||||
});
|
||||
toast.success(`${member.email} has been removed from the organization.`);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Failed to remove member');
|
||||
} finally {
|
||||
setConfirmRemove(false);
|
||||
}
|
||||
};
|
||||
|
||||
const memberName = [member.first_name, member.last_name]
|
||||
.filter(Boolean)
|
||||
.join(' ') || member.email;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu open={dropdownOpen} onOpenChange={setDropdownOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
aria-label={`Actions for ${memberName}`}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<UserMinus className="mr-2 h-4 w-4" />
|
||||
Remove Member
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Confirmation Dialog */}
|
||||
<AlertDialog open={confirmRemove} onOpenChange={setConfirmRemove}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove Member</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to remove {memberName} from this organization?
|
||||
This action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRemove}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* OrganizationMembersContent Component
|
||||
* Client-side content for the organization members management page
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import {
|
||||
useOrganizationMembers,
|
||||
useGetOrganization,
|
||||
type OrganizationMember,
|
||||
type PaginationMeta,
|
||||
} from '@/lib/api/hooks/useAdmin';
|
||||
import { OrganizationMembersTable } from './OrganizationMembersTable';
|
||||
import { AddMemberDialog } from './AddMemberDialog';
|
||||
|
||||
interface OrganizationMembersContentProps {
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export function OrganizationMembersContent({ organizationId }: OrganizationMembersContentProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { user: currentUser } = useAuth();
|
||||
|
||||
// URL state
|
||||
const page = parseInt(searchParams.get('page') || '1', 10);
|
||||
|
||||
// Local state
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
|
||||
// Fetch organization details
|
||||
const { data: organization, isLoading: isLoadingOrg } = useGetOrganization(organizationId);
|
||||
|
||||
// Fetch organization members with query params
|
||||
const { data, isLoading: isLoadingMembers } = useOrganizationMembers(organizationId, page, 20);
|
||||
|
||||
const members: OrganizationMember[] = data?.data || [];
|
||||
const pagination: PaginationMeta = data?.pagination || {
|
||||
total: 0,
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
total_pages: 1,
|
||||
has_next: false,
|
||||
has_prev: false,
|
||||
};
|
||||
|
||||
// istanbul ignore next - URL update helper fully tested in E2E
|
||||
// URL update helper
|
||||
const updateURL = useCallback(
|
||||
(params: Record<string, string | number | null>) => {
|
||||
const newParams = new URLSearchParams(searchParams.toString());
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value === null || value === '') {
|
||||
newParams.delete(key);
|
||||
} else {
|
||||
newParams.set(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
router.push(`?${newParams.toString()}`);
|
||||
},
|
||||
[searchParams, router]
|
||||
);
|
||||
|
||||
// istanbul ignore next - Event handlers fully tested in E2E
|
||||
const handlePageChange = (newPage: number) => {
|
||||
updateURL({ page: newPage });
|
||||
};
|
||||
|
||||
const handleAddMember = () => {
|
||||
setAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const orgName = (organization as { name?: string })?.name || 'Organization';
|
||||
const isLoading = isLoadingOrg || isLoadingMembers;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
{/* Header with Add Member Button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold tracking-tight">
|
||||
{orgName} Members
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Manage members and their roles within the organization
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleAddMember}>
|
||||
<UserPlus className="mr-2 h-4 w-4" />
|
||||
Add Member
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Organization Members Table */}
|
||||
<OrganizationMembersTable
|
||||
members={members}
|
||||
organizationId={organizationId}
|
||||
pagination={pagination}
|
||||
isLoading={isLoading}
|
||||
onPageChange={handlePageChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add Member Dialog */}
|
||||
<AddMemberDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* OrganizationMembersTable Component
|
||||
* Displays paginated list of organization members with roles and actions
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { MemberActionMenu } from './MemberActionMenu';
|
||||
import type { OrganizationMember, PaginationMeta } from '@/lib/api/hooks/useAdmin';
|
||||
|
||||
interface OrganizationMembersTableProps {
|
||||
members: OrganizationMember[];
|
||||
organizationId: string;
|
||||
pagination: PaginationMeta;
|
||||
isLoading: boolean;
|
||||
onPageChange: (page: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Role badge variant mapping
|
||||
*/
|
||||
const getRoleBadgeVariant = (role: string): 'default' | 'secondary' | 'outline' | 'destructive' => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return 'default';
|
||||
case 'admin':
|
||||
return 'secondary';
|
||||
case 'member':
|
||||
return 'outline';
|
||||
case 'guest':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Capitalize first letter of role
|
||||
*/
|
||||
const formatRole = (role: string): string => {
|
||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||
};
|
||||
|
||||
export function OrganizationMembersTable({
|
||||
members,
|
||||
organizationId,
|
||||
pagination,
|
||||
isLoading,
|
||||
onPageChange,
|
||||
}: OrganizationMembersTableProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="text-center">Role</TableHead>
|
||||
<TableHead>Joined</TableHead>
|
||||
<TableHead className="w-[70px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
// Loading skeleton
|
||||
Array.from({ length: 5 }).map((_, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[200px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[150px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-5 w-[80px] mx-auto" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-[100px]" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-8 w-8" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : members.length === 0 ? (
|
||||
// Empty state
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="h-24 text-center">
|
||||
No members found.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
// Member rows
|
||||
members.map((member) => {
|
||||
const fullName = [member.first_name, member.last_name]
|
||||
.filter(Boolean)
|
||||
.join(' ') || <span className="text-muted-foreground italic">No name</span>;
|
||||
|
||||
return (
|
||||
<TableRow key={member.user_id}>
|
||||
<TableCell className="font-medium">{member.email}</TableCell>
|
||||
<TableCell>{fullName}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={getRoleBadgeVariant(member.role)}>
|
||||
{formatRole(member.role)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{format(new Date(member.joined_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<MemberActionMenu
|
||||
member={member}
|
||||
organizationId={organizationId}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!isLoading && members.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.page_size + 1} to{' '}
|
||||
{Math.min(
|
||||
pagination.page * pagination.page_size,
|
||||
pagination.total
|
||||
)}{' '}
|
||||
of {pagination.total} members
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(pagination.page - 1)}
|
||||
disabled={!pagination.has_prev}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
{Array.from({ length: pagination.total_pages }, (_, i) => i + 1)
|
||||
.filter(
|
||||
(page) =>
|
||||
page === 1 ||
|
||||
page === pagination.total_pages ||
|
||||
Math.abs(page - pagination.page) <= 1
|
||||
)
|
||||
.map((page, idx, arr) => {
|
||||
const prevPage = arr[idx - 1];
|
||||
const showEllipsis = prevPage && page - prevPage > 1;
|
||||
|
||||
return (
|
||||
<div key={page} className="flex items-center">
|
||||
{showEllipsis && (
|
||||
<span className="px-2 text-muted-foreground">...</span>
|
||||
)}
|
||||
<Button
|
||||
variant={
|
||||
page === pagination.page ? 'default' : 'outline'
|
||||
}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}
|
||||
className="w-9"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(pagination.page + 1)}
|
||||
disabled={!pagination.has_next}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user