feat: UI pagination and members search (#724)

* feat: pagination (UI)

* Fixes

* feat: add search to member list page

* responsiveness
This commit is contained in:
Philip Okugbe
2025-02-13 23:28:00 +00:00
committed by GitHub
parent ae842f94d0
commit 0ef6b1978a
19 changed files with 605 additions and 426 deletions

View File

@@ -1,75 +1,84 @@
import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import React from "react";
import { useState } from "react";
import { Link } from "react-router-dom";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next";
import { formatMemberCount } from "@/lib";
import { IGroup } from "@/features/group/types/group.types.ts";
import Paginate from "@/components/common/paginate.tsx";
export default function GroupList() {
const { t } = useTranslation();
const { data, isLoading } = useGetGroupsQuery();
const [page, setPage] = useState(1);
const { data, isLoading } = useGetGroupsQuery({ page });
return (
<>
{data && (
<Table.ScrollContainer minWidth={400}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Group")}</Table.Th>
<Table.Th>{t("Members")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm" layout="fixed">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("Group")}</Table.Th>
<Table.Th>{t("Members")}</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((group: IGroup, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
<Group gap="sm" wrap="nowrap">
<IconGroupCircle />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{group.description}
</Text>
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
whiteSpace: "nowrap",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
{formatMemberCount(group.memberCount, t)}
</Anchor>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Table.Tbody>
{data?.items.map((group: IGroup, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
<Group gap="sm" wrap="nowrap">
<IconGroupCircle />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{group.name}
</Text>
<Text fz="xs" c="dimmed" lineClamp={2}>
{group.description}
</Text>
</div>
</Group>
</Anchor>
</Table.Td>
<Table.Td>
<Anchor
size="sm"
underline="never"
style={{
cursor: "pointer",
color: "var(--mantine-color-text)",
whiteSpace: "nowrap",
}}
component={Link}
to={`/settings/groups/${group.id}`}
>
{formatMemberCount(group.memberCount, t)}
</Anchor>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</>
);

View File

@@ -4,18 +4,20 @@ import {
useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom";
import React from "react";
import React, { useState } from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts";
import Paginate from "@/components/common/paginate.tsx";
export default function GroupMembersList() {
const { t } = useTranslation();
const { groupId } = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId);
const [page, setPage] = useState(1);
const { data, isLoading } = useGroupMembersQuery(groupId, { page });
const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole();
@@ -45,67 +47,71 @@ export default function GroupMembersList() {
return (
<>
{data && (
<Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("User")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>{t("User")}</Table.Th>
<Table.Th>{t("Status")}</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm">
<CustomAvatar
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div>
<Text fz="sm" fw={500}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
{t("Remove group member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
<Table.Tbody>
{data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
<div>
<Text fz="sm" fw={500} lineClamp={1}>
{user.name}
</Text>
<Text fz="xs" c="dimmed">
{user.email}
</Text>
</div>
</Group>
</Table.Td>
<Table.Td>
<Badge variant="light">{t("Active")}</Badge>
</Table.Td>
<Table.Td>
{isAdmin && (
<Menu
shadow="xl"
position="bottom-end"
offset={20}
width={200}
withArrow
arrowPosition="center"
>
<Menu.Target>
<ActionIcon variant="subtle" c="gray">
<IconDots size={20} stroke={2} />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => openRemoveModal(user.id)}>
{t("Remove group member")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)}
</>
);

View File

@@ -14,14 +14,14 @@ interface MultiUserSelectProps {
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
option,
}) => (
<Group gap="sm">
<Group gap="sm" wrap="nowrap">
<CustomAvatar
avatarUrl={option?.["avatarUrl"]}
name={option.label}
size={36}
/>
<div>
<Text size="sm">{option.label}</Text>
<Text size="sm" lineClamp={1}>{option.label}</Text>
<Text size="xs" opacity={0.5}>
{option?.["email"]}
</Text>

View File

@@ -3,8 +3,9 @@ import {
useQuery,
useQueryClient,
UseQueryResult,
} from '@tanstack/react-query';
import { IGroup } from '@/features/group/types/group.types';
keepPreviousData,
} from "@tanstack/react-query";
import { IGroup } from "@/features/group/types/group.types";
import {
addGroupMember,
createGroup,
@@ -14,22 +15,24 @@ import {
getGroups,
removeGroupMember,
updateGroup,
} from '@/features/group/services/group-service';
import { notifications } from '@mantine/notifications';
import { QueryParams } from '@/lib/types.ts';
} from "@/features/group/services/group-service";
import { notifications } from "@mantine/notifications";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
export function useGetGroupsQuery(
params?: QueryParams
): UseQueryResult<any, Error> {
params?: QueryParams,
): UseQueryResult<IPagination<IGroup>, Error> {
return useQuery({
queryKey: ['groups', params],
queryKey: ["groups", params],
queryFn: () => getGroups(params),
placeholderData: keepPreviousData,
});
}
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
return useQuery({
queryKey: ['group', groupId],
queryKey: ["group", groupId],
queryFn: () => getGroupById(groupId),
enabled: !!groupId,
});
@@ -42,13 +45,13 @@ export function useCreateGroupMutation() {
mutationFn: (data) => createGroup(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ['groups'],
queryKey: ["groups"],
});
notifications.show({ message: 'Group created successfully' });
notifications.show({ message: "Group created successfully" });
},
onError: () => {
notifications.show({ message: 'Failed to create group', color: 'red' });
notifications.show({ message: "Failed to create group", color: "red" });
},
});
}
@@ -59,14 +62,14 @@ export function useUpdateGroupMutation() {
return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => {
notifications.show({ message: 'Group updated successfully' });
notifications.show({ message: "Group updated successfully" });
queryClient.invalidateQueries({
queryKey: ['group', variables.groupId],
queryKey: ["group", variables.groupId],
});
},
onError: (error) => {
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
@@ -77,28 +80,25 @@ export function useDeleteGroupMutation() {
return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => {
notifications.show({ message: 'Group deleted successfully' });
const groups = queryClient.getQueryData(['groups']) as any;
if (groups) {
groups.items = groups.items?.filter(
(group: IGroup) => group.id !== variables
);
queryClient.setQueryData(['groups'], groups);
}
notifications.show({ message: "Group deleted successfully" });
queryClient.refetchQueries({ queryKey: ["groups"] });
},
onError: (error) => {
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}
export function useGroupMembersQuery(groupId: string) {
export function useGroupMembersQuery(
groupId: string,
params?: QueryParams,
): UseQueryResult<IPagination<IUser>, Error> {
return useQuery({
queryKey: ['groupMembers', groupId],
queryFn: () => getGroupMembers(groupId),
queryKey: ["groupMembers", groupId, params],
queryFn: () => getGroupMembers(groupId, params),
enabled: !!groupId,
placeholderData: keepPreviousData,
});
}
@@ -108,15 +108,15 @@ export function useAddGroupMemberMutation() {
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: 'Added successfully' });
notifications.show({ message: "Added successfully" });
queryClient.invalidateQueries({
queryKey: ['groupMembers', variables.groupId],
queryKey: ["groupMembers", variables.groupId],
});
},
onError: () => {
notifications.show({
message: 'Failed to add group members',
color: 'red',
message: "Failed to add group members",
color: "red",
});
},
});
@@ -135,14 +135,14 @@ export function useRemoveGroupMemberMutation() {
>({
mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: 'Removed successfully' });
notifications.show({ message: "Removed successfully" });
queryClient.invalidateQueries({
queryKey: ['groupMembers', variables.groupId],
queryKey: ["groupMembers", variables.groupId],
});
},
onError: (error) => {
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
},
});
}

View File

@@ -1,10 +1,12 @@
import api from "@/lib/api-client";
import { IGroup } from "@/features/group/types/group.types";
import { QueryParams } from "@/lib/types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
export async function getGroups(params?: QueryParams): Promise<any> {
// TODO: returns paginated. Fix type
const req = await api.post<any>("/groups", params);
export async function getGroups(
params?: QueryParams,
): Promise<IPagination<IGroup>> {
const req = await api.post("/groups", params);
return req.data;
}
@@ -27,8 +29,11 @@ export async function deleteGroup(data: { groupId: string }): Promise<void> {
await api.post("/groups/delete", data);
}
export async function getGroupMembers(groupId: string) {
const req = await api.post("/groups/members", { groupId });
export async function getGroupMembers(
groupId: string,
params?: QueryParams,
): Promise<IPagination<IUser>> {
const req = await api.post("/groups/members", { groupId, params });
return req.data;
}