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

@ -0,0 +1,19 @@
import { Table, Text } from "@mantine/core";
import React from "react";
import { useTranslation } from "react-i18next";
interface NoTableResultsProps {
colSpan: number;
}
export default function NoTableResults({ colSpan }: NoTableResultsProps) {
const { t } = useTranslation();
return (
<Table.Tr>
<Table.Td colSpan={colSpan}>
<Text fw={500} c="dimmed" ta="center">
{t("No results found...")}
</Text>
</Table.Td>
</Table.Tr>
);
}

View File

@ -0,0 +1,40 @@
import { Button, Group } from "@mantine/core";
import { useTranslation } from "react-i18next";
export interface PagePaginationProps {
currentPage: number;
hasPrevPage: boolean;
hasNextPage: boolean;
onPageChange: (newPage: number) => void;
}
export default function Paginate({
currentPage,
hasPrevPage,
hasNextPage,
onPageChange,
}: PagePaginationProps) {
const { t } = useTranslation();
return (
<Group mt="md">
<Button
variant="default"
size="compact-sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={!hasPrevPage}
>
{t("Prev")}
</Button>
<Button
variant="default"
size="compact-sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={!hasNextPage}
>
{t("Next")}
</Button>
</Group>
);
}

View File

@ -0,0 +1,36 @@
import React, { useState, useEffect } from "react";
import { TextInput, Group } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconSearch } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export interface SearchInputProps {
placeholder?: string;
debounceDelay?: number;
onSearch: (value: string) => void;
}
export function SearchInput({
placeholder,
debounceDelay = 500,
onSearch,
}: SearchInputProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
const [debouncedValue] = useDebouncedValue(value, debounceDelay);
useEffect(() => {
onSearch(debouncedValue);
}, [debouncedValue, onSearch]);
return (
<Group mb="md">
<TextInput
placeholder={placeholder || t("Search...")}
leftSection={<IconSearch size={14} />}
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
</Group>
);
}

View File

@ -38,7 +38,7 @@ export default function TopMenu() {
variant="filled" variant="filled"
size="sm" size="sm"
/> />
<Text fw={500} size="sm" lh={1} mr={3}> <Text fw={500} size="sm" lh={1} mr={3} lineClamp={1}>
{workspace.name} {workspace.name}
</Text> </Text>
<IconChevronDown size={16} /> <IconChevronDown size={16} />

View File

@ -1,21 +1,22 @@
import { Table, Group, Text, Anchor } from "@mantine/core"; import { Table, Group, Text, Anchor } from "@mantine/core";
import { useGetGroupsQuery } from "@/features/group/queries/group-query"; import { useGetGroupsQuery } from "@/features/group/queries/group-query";
import React from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { IGroup } from "@/features/group/types/group.types.ts"; import { IGroup } from "@/features/group/types/group.types.ts";
import Paginate from "@/components/common/paginate.tsx";
export default function GroupList() { export default function GroupList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading } = useGetGroupsQuery(); const [page, setPage] = useState(1);
const { data, isLoading } = useGetGroupsQuery({ page });
return ( return (
<> <>
{data && ( <Table.ScrollContainer minWidth={500}>
<Table.ScrollContainer minWidth={400}> <Table highlightOnHover verticalSpacing="sm" layout="fixed">
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("Group")}</Table.Th> <Table.Th>{t("Group")}</Table.Th>
@ -70,6 +71,14 @@ export default function GroupList() {
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Table.ScrollContainer> </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, useRemoveGroupMemberMutation,
} from "@/features/group/queries/group-query"; } from "@/features/group/queries/group-query";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import React from "react"; import React, { useState } from "react";
import { IconDots } from "@tabler/icons-react"; import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import Paginate from "@/components/common/paginate.tsx";
export default function GroupMembersList() { export default function GroupMembersList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { groupId } = useParams(); const { groupId } = useParams();
const { data, isLoading } = useGroupMembersQuery(groupId); const [page, setPage] = useState(1);
const { data, isLoading } = useGroupMembersQuery(groupId, { page });
const removeGroupMember = useRemoveGroupMemberMutation(); const removeGroupMember = useRemoveGroupMemberMutation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
@ -45,9 +47,8 @@ export default function GroupMembersList() {
return ( return (
<> <>
{data && (
<Table.ScrollContainer minWidth={500}> <Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("User")}</Table.Th> <Table.Th>{t("User")}</Table.Th>
@ -60,13 +61,10 @@ export default function GroupMembersList() {
{data?.items.map((user: IUser, index: number) => ( {data?.items.map((user: IUser, index: number) => (
<Table.Tr key={index}> <Table.Tr key={index}>
<Table.Td> <Table.Td>
<Group gap="sm"> <Group gap="sm" wrap="nowrap">
<CustomAvatar <CustomAvatar avatarUrl={user.avatarUrl} name={user.name} />
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div> <div>
<Text fz="sm" fw={500}> <Text fz="sm" fw={500} lineClamp={1}>
{user.name} {user.name}
</Text> </Text>
<Text fz="xs" c="dimmed"> <Text fz="xs" c="dimmed">
@ -106,6 +104,14 @@ export default function GroupMembersList() {
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Table.ScrollContainer> </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"] = ({ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
option, option,
}) => ( }) => (
<Group gap="sm"> <Group gap="sm" wrap="nowrap">
<CustomAvatar <CustomAvatar
avatarUrl={option?.["avatarUrl"]} avatarUrl={option?.["avatarUrl"]}
name={option.label} name={option.label}
size={36} size={36}
/> />
<div> <div>
<Text size="sm">{option.label}</Text> <Text size="sm" lineClamp={1}>{option.label}</Text>
<Text size="xs" opacity={0.5}> <Text size="xs" opacity={0.5}>
{option?.["email"]} {option?.["email"]}
</Text> </Text>

View File

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

View File

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

View File

@ -15,7 +15,7 @@ interface MultiMemberSelectProps {
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
option, option,
}) => ( }) => (
<Group gap="sm"> <Group gap="sm" wrap="nowrap">
{option["type"] === "user" && ( {option["type"] === "user" && (
<CustomAvatar <CustomAvatar
avatarUrl={option["avatarUrl"]} avatarUrl={option["avatarUrl"]}
@ -25,7 +25,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
)} )}
{option["type"] === "group" && <IconGroupCircle />} {option["type"] === "group" && <IconGroupCircle />}
<div> <div>
<Text size="sm">{option.label}</Text> <Text size="sm" lineClamp={1}>{option.label}</Text>
</div> </div>
</Group> </Group>
); );

View File

@ -12,10 +12,10 @@ interface SpaceSelectProps {
} }
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => ( const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
<Group gap="sm"> <Group gap="sm" wrap="nowrap">
<Avatar color="initials" variant="filled" name={option.label} size={20} /> <Avatar color="initials" variant="filled" name={option.label} size={20} />
<div> <div>
<Text size="sm">{option.label}</Text> <Text size="sm" lineClamp={1}>{option.label}</Text>
</div> </div>
</Group> </Group>
); );

View File

@ -5,10 +5,12 @@ import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
export default function SpaceList() { export default function SpaceList() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading } = useGetSpacesQuery(); const [page, setPage] = useState(1);
const { data, isLoading } = useGetSpacesQuery({ page });
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null); const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
@ -19,9 +21,8 @@ export default function SpaceList() {
return ( return (
<> <>
{data && ( <Table.ScrollContainer minWidth={500}>
<Table.ScrollContainer minWidth={400}> <Table highlightOnHover verticalSpacing="sm" layout="fixed">
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("Space")}</Table.Th> <Table.Th>{t("Space")}</Table.Th>
@ -63,6 +64,14 @@ export default function SpaceList() {
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)} )}
{selectedSpaceId && ( {selectedSpaceId && (

View File

@ -1,5 +1,5 @@
import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core"; import { Group, Table, Text, Menu, ActionIcon } from "@mantine/core";
import React from "react"; import React, { useState } from "react";
import { IconDots } from "@tabler/icons-react"; import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals"; import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
@ -17,6 +17,7 @@ import {
} from "@/features/space/types/space-role-data.ts"; } from "@/features/space/types/space-role-data.ts";
import { formatMemberCount } from "@/lib"; import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
type MemberType = "user" | "group"; type MemberType = "user" | "group";
@ -30,8 +31,11 @@ export default function SpaceMembersList({
readOnly, readOnly,
}: SpaceMembersProps) { }: SpaceMembersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading } = useSpaceMembersQuery(spaceId); const [page, setPage] = useState(1);
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
page,
limit: 100,
});
const removeSpaceMember = useRemoveSpaceMemberMutation(); const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation(); const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@ -98,9 +102,8 @@ export default function SpaceMembersList({
return ( return (
<> <>
{data && (
<Table.ScrollContainer minWidth={500}> <Table.ScrollContainer minWidth={500}>
<Table verticalSpacing={8}> <Table highlightOnHover verticalSpacing={8}>
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("Member")}</Table.Th> <Table.Th>{t("Member")}</Table.Th>
@ -113,7 +116,7 @@ export default function SpaceMembersList({
{data?.items.map((member, index) => ( {data?.items.map((member, index) => (
<Table.Tr key={index}> <Table.Tr key={index}>
<Table.Td> <Table.Td>
<Group gap="sm"> <Group gap="sm" wrap="nowrap">
{member.type === "user" && ( {member.type === "user" && (
<CustomAvatar <CustomAvatar
avatarUrl={member?.avatarUrl} avatarUrl={member?.avatarUrl}
@ -124,7 +127,7 @@ export default function SpaceMembersList({
{member.type === "group" && <IconGroupCircle />} {member.type === "group" && <IconGroupCircle />}
<div> <div>
<Text fz="sm" fw={500}> <Text fz="sm" fw={500} lineClamp={1}>
{member?.name} {member?.name}
</Text> </Text>
<Text fz="xs" c="dimmed"> <Text fz="xs" c="dimmed">
@ -186,6 +189,14 @@ export default function SpaceMembersList({
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)} )}
</> </>
); );

View File

@ -1,16 +1,17 @@
import { import {
keepPreviousData,
useMutation, useMutation,
useQuery, useQuery,
useQueryClient, useQueryClient,
UseQueryResult, UseQueryResult,
} from '@tanstack/react-query'; } from "@tanstack/react-query";
import { import {
IAddSpaceMember, IAddSpaceMember,
IChangeSpaceMemberRole, IChangeSpaceMemberRole,
IRemoveSpaceMember, IRemoveSpaceMember,
ISpace, ISpace,
ISpaceMember, ISpaceMember,
} from '@/features/space/types/space.types'; } from "@/features/space/types/space.types";
import { import {
addSpaceMember, addSpaceMember,
changeMemberRole, changeMemberRole,
@ -21,22 +22,23 @@ import {
createSpace, createSpace,
updateSpace, updateSpace,
deleteSpace, deleteSpace,
} from '@/features/space/services/space-service.ts'; } from "@/features/space/services/space-service.ts";
import { notifications } from '@mantine/notifications'; import { notifications } from "@mantine/notifications";
import { IPagination, QueryParams } from '@/lib/types.ts'; import { IPagination, QueryParams } from "@/lib/types.ts";
export function useGetSpacesQuery( export function useGetSpacesQuery(
params?: QueryParams params?: QueryParams,
): UseQueryResult<IPagination<ISpace>, Error> { ): UseQueryResult<IPagination<ISpace>, Error> {
return useQuery({ return useQuery({
queryKey: ['spaces', params], queryKey: ["spaces", params],
queryFn: () => getSpaces(params), queryFn: () => getSpaces(params),
placeholderData: keepPreviousData,
}); });
} }
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> { export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
return useQuery({ return useQuery({
queryKey: ['space', spaceId], queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId), queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@ -50,22 +52,22 @@ export function useCreateSpaceMutation() {
mutationFn: (data) => createSpace(data), mutationFn: (data) => createSpace(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['spaces'], queryKey: ["spaces"],
}); });
notifications.show({ message: 'Space created successfully' }); notifications.show({ message: "Space created successfully" });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error['response']?.data?.message; const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: 'red' }); notifications.show({ message: errorMessage, color: "red" });
}, },
}); });
} }
export function useGetSpaceBySlugQuery( export function useGetSpaceBySlugQuery(
spaceId: string spaceId: string,
): UseQueryResult<ISpace, Error> { ): UseQueryResult<ISpace, Error> {
return useQuery({ return useQuery({
queryKey: ['space', spaceId], queryKey: ["space", spaceId],
queryFn: () => getSpaceById(spaceId), queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId, enabled: !!spaceId,
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
@ -78,25 +80,25 @@ export function useUpdateSpaceMutation() {
return useMutation<ISpace, Error, Partial<ISpace>>({ return useMutation<ISpace, Error, Partial<ISpace>>({
mutationFn: (data) => updateSpace(data), mutationFn: (data) => updateSpace(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: 'Space updated successfully' }); notifications.show({ message: "Space updated successfully" });
const space = queryClient.getQueryData([ const space = queryClient.getQueryData([
'space', "space",
variables.spaceId, variables.spaceId,
]) as ISpace; ]) as ISpace;
if (space) { if (space) {
const updatedSpace = { ...space, ...data }; const updatedSpace = { ...space, ...data };
queryClient.setQueryData(['space', variables.spaceId], updatedSpace); queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
queryClient.setQueryData(['space', data.slug], updatedSpace); queryClient.setQueryData(["space", data.slug], updatedSpace);
} }
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['spaces'], queryKey: ["spaces"],
}); });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error['response']?.data?.message; const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: 'red' }); notifications.show({ message: errorMessage, color: "red" });
}, },
}); });
} }
@ -107,37 +109,39 @@ export function useDeleteSpaceMutation() {
return useMutation({ return useMutation({
mutationFn: (data: Partial<ISpace>) => deleteSpace(data.id), mutationFn: (data: Partial<ISpace>) => deleteSpace(data.id),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: 'Space deleted successfully' }); notifications.show({ message: "Space deleted successfully" });
if (variables.slug) { if (variables.slug) {
queryClient.removeQueries({ queryClient.removeQueries({
queryKey: ['space', variables.slug], queryKey: ["space", variables.slug],
exact: true, exact: true,
}); });
} }
const spaces = queryClient.getQueryData(['spaces']) as any; const spaces = queryClient.getQueryData(["spaces"]) as any;
if (spaces) { if (spaces) {
spaces.items = spaces.items?.filter( spaces.items = spaces.items?.filter(
(space: ISpace) => space.id !== variables.id (space: ISpace) => space.id !== variables.id,
); );
queryClient.setQueryData(['spaces'], spaces); queryClient.setQueryData(["spaces"], spaces);
} }
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error['response']?.data?.message; const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: 'red' }); notifications.show({ message: errorMessage, color: "red" });
}, },
}); });
} }
export function useSpaceMembersQuery( export function useSpaceMembersQuery(
spaceId: string spaceId: string,
params?: QueryParams,
): UseQueryResult<IPagination<ISpaceMember>, Error> { ): UseQueryResult<IPagination<ISpaceMember>, Error> {
return useQuery({ return useQuery({
queryKey: ['spaceMembers', spaceId], queryKey: ["spaceMembers", spaceId, params],
queryFn: () => getSpaceMembers(spaceId), queryFn: () => getSpaceMembers(spaceId, params),
enabled: !!spaceId, enabled: !!spaceId,
placeholderData: keepPreviousData,
}); });
} }
@ -147,14 +151,14 @@ export function useAddSpaceMemberMutation() {
return useMutation<void, Error, IAddSpaceMember>({ return useMutation<void, Error, IAddSpaceMember>({
mutationFn: (data) => addSpaceMember(data), mutationFn: (data) => addSpaceMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: 'Members added successfully' }); notifications.show({ message: "Members added successfully" });
queryClient.invalidateQueries({ queryClient.invalidateQueries({
queryKey: ['spaceMembers', variables.spaceId], queryKey: ["spaceMembers", variables.spaceId],
}); });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error['response']?.data?.message; const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: 'red' }); notifications.show({ message: errorMessage, color: "red" });
}, },
}); });
} }
@ -165,14 +169,14 @@ export function useRemoveSpaceMemberMutation() {
return useMutation<void, Error, IRemoveSpaceMember>({ return useMutation<void, Error, IRemoveSpaceMember>({
mutationFn: (data) => removeSpaceMember(data), mutationFn: (data) => removeSpaceMember(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: 'Removed successfully' }); notifications.show({ message: "Removed successfully" });
queryClient.refetchQueries({ queryClient.invalidateQueries({
queryKey: ['spaceMembers', variables.spaceId], queryKey: ["spaceMembers", variables.spaceId],
}); });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error['response']?.data?.message; const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: 'red' }); notifications.show({ message: errorMessage, color: "red" });
}, },
}); });
} }
@ -183,15 +187,15 @@ export function useChangeSpaceMemberRoleMutation() {
return useMutation<void, Error, IChangeSpaceMemberRole>({ return useMutation<void, Error, IChangeSpaceMemberRole>({
mutationFn: (data) => changeMemberRole(data), mutationFn: (data) => changeMemberRole(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: 'Member role updated successfully' }); notifications.show({ message: "Member role updated successfully" });
// due to pagination levels, change in cache instead // due to pagination levels, change in cache instead
queryClient.refetchQueries({ queryClient.refetchQueries({
queryKey: ['spaceMembers', variables.spaceId], queryKey: ["spaceMembers", variables.spaceId],
}); });
}, },
onError: (error) => { onError: (error) => {
const errorMessage = error['response']?.data?.message; const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: 'red' }); notifications.show({ message: errorMessage, color: "red" });
}, },
}); });
} }

View File

@ -5,13 +5,14 @@ import {
IExportSpaceParams, IExportSpaceParams,
IRemoveSpaceMember, IRemoveSpaceMember,
ISpace, ISpace,
ISpaceMember,
} from "@/features/space/types/space.types"; } from "@/features/space/types/space.types";
import { IPagination, QueryParams } from "@/lib/types.ts"; import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts"; import { IUser } from "@/features/user/types/user.types.ts";
import { saveAs } from "file-saver"; import { saveAs } from "file-saver";
export async function getSpaces( export async function getSpaces(
params?: QueryParams params?: QueryParams,
): Promise<IPagination<ISpace>> { ): Promise<IPagination<ISpace>> {
const req = await api.post("/spaces", params); const req = await api.post("/spaces", params);
return req.data; return req.data;
@ -37,9 +38,10 @@ export async function deleteSpace(spaceId: string): Promise<void> {
} }
export async function getSpaceMembers( export async function getSpaceMembers(
spaceId: string spaceId: string,
): Promise<IPagination<IUser>> { params?: QueryParams,
const req = await api.post<any>("/spaces/members", { spaceId }); ): Promise<IPagination<ISpaceMember>> {
const req = await api.post<any>("/spaces/members", { spaceId, params });
return req.data; return req.data;
} }
@ -48,13 +50,13 @@ export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
} }
export async function removeSpaceMember( export async function removeSpaceMember(
data: IRemoveSpaceMember data: IRemoveSpaceMember,
): Promise<void> { ): Promise<void> {
await api.post("/spaces/members/remove", data); await api.post("/spaces/members/remove", data);
} }
export async function changeMemberRole( export async function changeMemberRole(
data: IChangeSpaceMemberRole data: IChangeSpaceMemberRole,
): Promise<void> { ): Promise<void> {
await api.post("/spaces/members/change-role", data); await api.post("/spaces/members/change-role", data);
} }

View File

@ -1,19 +1,22 @@
import {Group, Table, Avatar, Text, Alert} from "@mantine/core"; import { Group, Table, Avatar, Text, Alert } from "@mantine/core";
import {useWorkspaceInvitationsQuery} from "@/features/workspace/queries/workspace-query.ts"; import { useWorkspaceInvitationsQuery } from "@/features/workspace/queries/workspace-query.ts";
import React from "react"; import React, { useState } from "react";
import {getUserRoleLabel} from "@/features/workspace/types/user-role-data.ts"; import { getUserRoleLabel } from "@/features/workspace/types/user-role-data.ts";
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx"; import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
import {IconInfoCircle} from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import {formattedDate, timeAgo} from "@/lib/time.ts"; import { timeAgo } from "@/lib/time.ts";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
export default function WorkspaceInvitesTable() { export default function WorkspaceInvitesTable() {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(1);
const { data, isLoading } = useWorkspaceInvitationsQuery({ const { data, isLoading } = useWorkspaceInvitationsQuery({
page,
limit: 100, limit: 100,
}); });
const {isAdmin} = useUserRole(); const { isAdmin } = useUserRole();
return ( return (
<> <>
@ -23,10 +26,8 @@ export default function WorkspaceInvitesTable() {
)} )}
</Alert> </Alert>
{data && (
<>
<Table.ScrollContainer minWidth={500}> <Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("Email")}</Table.Th> <Table.Th>{t("Email")}</Table.Th>
@ -39,8 +40,8 @@ export default function WorkspaceInvitesTable() {
{data?.items.map((invitation, index) => ( {data?.items.map((invitation, index) => (
<Table.Tr key={index}> <Table.Tr key={index}>
<Table.Td> <Table.Td>
<Group gap="sm"> <Group gap="sm" wrap="nowrap">
<Avatar name={invitation.email} color="initials"/> <Avatar name={invitation.email} color="initials" />
<div> <div>
<Text fz="sm" fw={500}> <Text fz="sm" fw={500}>
{invitation.email} {invitation.email}
@ -54,16 +55,21 @@ export default function WorkspaceInvitesTable() {
<Table.Td>{timeAgo(invitation.createdAt)}</Table.Td> <Table.Td>{timeAgo(invitation.createdAt)}</Table.Td>
<Table.Td> <Table.Td>
{isAdmin && ( {isAdmin && <InviteActionMenu invitationId={invitation.id} />}
<InviteActionMenu invitationId={invitation.id}/>
)}
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>
</>
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)} )}
</> </>
); );

View File

@ -1,10 +1,10 @@
import {Group, Table, Text, Badge} from "@mantine/core"; import { Group, Table, Text, Badge } from "@mantine/core";
import { import {
useChangeMemberRoleMutation, useChangeMemberRoleMutation,
useWorkspaceMembersQuery, useWorkspaceMembersQuery,
} from "@/features/workspace/queries/workspace-query.ts"; } from "@/features/workspace/queries/workspace-query.ts";
import {CustomAvatar} from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import React from "react"; import React, { useState } from "react";
import RoleSelectMenu from "@/components/ui/role-select-menu.tsx"; import RoleSelectMenu from "@/components/ui/role-select-menu.tsx";
import { import {
getUserRoleLabel, getUserRoleLabel,
@ -13,12 +13,21 @@ import {
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { UserRole } from "@/lib/types.ts"; import { UserRole } from "@/lib/types.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx";
import NoTableResults from "@/components/common/no-table-results.tsx";
export default function WorkspaceMembersTable() { export default function WorkspaceMembersTable() {
const { t } = useTranslation(); const { t } = useTranslation();
const { data, isLoading } = useWorkspaceMembersQuery({ limit: 100 }); const [page, setPage] = useState(1);
const [search, setSearch] = useState(undefined);
const { data, isLoading } = useWorkspaceMembersQuery({
page,
limit: 100,
query: search,
});
const changeMemberRoleMutation = useChangeMemberRoleMutation(); const changeMemberRoleMutation = useChangeMemberRoleMutation();
const {isAdmin, isOwner} = useUserRole(); const { isAdmin, isOwner } = useUserRole();
const assignableUserRoles = isOwner const assignableUserRoles = isOwner
? userRoleData ? userRoleData
@ -43,9 +52,14 @@ export default function WorkspaceMembersTable() {
return ( return (
<> <>
{data && ( <SearchInput
onSearch={(debouncedSearch) => {
setSearch(debouncedSearch);
setPage(1);
}}
/>
<Table.ScrollContainer minWidth={500}> <Table.ScrollContainer minWidth={500}>
<Table verticalSpacing="sm"> <Table highlightOnHover verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>{t("User")}</Table.Th> <Table.Th>{t("User")}</Table.Th>
@ -55,13 +69,17 @@ export default function WorkspaceMembersTable() {
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{data?.items.map((user, index) => ( {data?.items.length > 0 ? (
data?.items.map((user, index) => (
<Table.Tr key={index}> <Table.Tr key={index}>
<Table.Td> <Table.Td>
<Group gap="sm"> <Group gap="sm" wrap="nowrap">
<CustomAvatar avatarUrl={user.avatarUrl} name={user.name}/> <CustomAvatar
avatarUrl={user.avatarUrl}
name={user.name}
/>
<div> <div>
<Text fz="sm" fw={500}> <Text fz="sm" fw={500} lineClamp={1}>
{user.name} {user.name}
</Text> </Text>
<Text fz="xs" c="dimmed"> <Text fz="xs" c="dimmed">
@ -84,10 +102,21 @@ export default function WorkspaceMembersTable() {
/> />
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))
) : (
<NoTableResults colSpan={3} />
)}
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>
{data?.items.length > 0 && (
<Paginate
currentPage={page}
hasPrevPage={data?.meta.hasPrevPage}
hasNextPage={data?.meta.hasNextPage}
onPageChange={setPage}
/>
)} )}
</> </>
); );

View File

@ -1,4 +1,5 @@
import { import {
keepPreviousData,
useMutation, useMutation,
useQuery, useQuery,
useQueryClient, useQueryClient,
@ -22,6 +23,7 @@ import {
IInvitation, IInvitation,
IWorkspace, IWorkspace,
} from "@/features/workspace/types/workspace.types.ts"; } from "@/features/workspace/types/workspace.types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
export function useWorkspaceQuery(): UseQueryResult<IWorkspace, Error> { export function useWorkspaceQuery(): UseQueryResult<IWorkspace, Error> {
return useQuery({ return useQuery({
@ -40,10 +42,13 @@ export function useWorkspacePublicDataQuery(): UseQueryResult<
}); });
} }
export function useWorkspaceMembersQuery(params?: QueryParams) { export function useWorkspaceMembersQuery(
params?: QueryParams,
): UseQueryResult<IPagination<IUser>, Error> {
return useQuery({ return useQuery({
queryKey: ["workspaceMembers", params], queryKey: ["workspaceMembers", params],
queryFn: () => getWorkspaceMembers(params), queryFn: () => getWorkspaceMembers(params),
placeholderData: keepPreviousData,
}); });
} }
@ -53,7 +58,6 @@ export function useChangeMemberRoleMutation() {
return useMutation<any, Error, any>({ return useMutation<any, Error, any>({
mutationFn: (data) => changeMemberRole(data), mutationFn: (data) => changeMemberRole(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
// TODO: change in cache instead
notifications.show({ message: "Member role updated successfully" }); notifications.show({ message: "Member role updated successfully" });
queryClient.refetchQueries({ queryClient.refetchQueries({
queryKey: ["workspaceMembers"], queryKey: ["workspaceMembers"],
@ -72,6 +76,7 @@ export function useWorkspaceInvitationsQuery(
return useQuery({ return useQuery({
queryKey: ["invitations", params], queryKey: ["invitations", params],
queryFn: () => getPendingInvitations(params), queryFn: () => getPendingInvitations(params),
placeholderData: keepPreviousData,
}); });
} }
@ -82,7 +87,6 @@ export function useCreateInvitationMutation() {
mutationFn: (data) => createInvitation(data), mutationFn: (data) => createInvitation(data),
onSuccess: (data, variables) => { onSuccess: (data, variables) => {
notifications.show({ message: "Invitation sent" }); notifications.show({ message: "Invitation sent" });
// TODO: mutate cache
queryClient.refetchQueries({ queryClient.refetchQueries({
queryKey: ["invitations"], queryKey: ["invitations"],
}); });

View File

@ -18,7 +18,6 @@ export async function getWorkspacePublicData(): Promise<IWorkspace> {
return req.data; return req.data;
} }
// Todo: fix all paginated types
export async function getWorkspaceMembers( export async function getWorkspaceMembers(
params?: QueryParams, params?: QueryParams,
): Promise<IPagination<IUser>> { ): Promise<IPagination<IUser>> {