fix: space members view enhancement

This commit is contained in:
Philipinho
2026-03-02 21:33:15 +00:00
parent 17ec2f4ac5
commit 90c190df78
3 changed files with 84 additions and 43 deletions
@@ -1,19 +1,21 @@
import {
Center,
Group,
Loader,
Table,
Text,
Menu,
ActionIcon,
ScrollArea,
} from "@mantine/core";
import React from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { IconDots } from "@tabler/icons-react";
import { modals } from "@mantine/modals";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import {
useChangeSpaceMemberRoleMutation,
useRemoveSpaceMemberMutation,
useSpaceMembersQuery,
useSpaceMembersInfiniteQuery,
} from "@/features/space/queries/space-query.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts";
@@ -24,9 +26,7 @@ import {
} from "@/features/space/types/space-role-data.ts";
import { formatMemberCount } from "@/lib";
import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { SearchInput } from "@/components/common/search-input.tsx";
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
type MemberType = "user" | "group";
@@ -41,12 +41,32 @@ export default function SpaceMembersList({
readOnly,
}: SpaceMembersProps) {
const { t } = useTranslation();
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch();
const { data, isLoading } = useSpaceMembersQuery(spaceId, {
cursor,
limit: 100,
query: search,
});
const [search, setSearch] = useState("");
const handleSearch = useCallback((query: string) => setSearch(query), []);
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
useSpaceMembersInfiniteQuery(spaceId, search);
const sentinelRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ root: viewportRef.current, threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@@ -111,10 +131,12 @@ export default function SpaceMembersList({
onConfirm: () => onRemove(memberId, type),
});
const members = data?.pages.flatMap((page) => page.items) ?? [];
return (
<>
<SearchInput onSearch={handleSearch} />
<ScrollArea h={450}>
<ScrollArea h={450} viewportRef={viewportRef}>
<Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing={8}>
<Table.Thead>
@@ -126,7 +148,7 @@ export default function SpaceMembersList({
</Table.Thead>
<Table.Tbody>
{data?.items.map((member, index) => (
{members.map((member, index) => (
<Table.Tr key={index}>
<Table.Td>
<Group gap="sm" wrap="nowrap">
@@ -154,6 +176,11 @@ export default function SpaceMembersList({
</Table.Td>
<Table.Td>
{readOnly ? (
<Text fz="sm">
{t(getSpaceRoleLabel(member.role))}
</Text>
) : (
<RoleSelectMenu
roles={spaceRoleData}
roleName={getSpaceRoleLabel(member.role)}
@@ -165,8 +192,8 @@ export default function SpaceMembersList({
member.role,
)
}
disabled={readOnly}
/>
)}
</Table.Td>
<Table.Td>
@@ -202,16 +229,15 @@ export default function SpaceMembersList({
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</ScrollArea>
{data?.items.length > 0 && (
<Paginate
hasPrevPage={data?.meta?.hasPrevPage}
hasNextPage={data?.meta?.hasNextPage}
onNext={() => goNext(data?.meta?.nextCursor)}
onPrev={goPrev}
/>
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</ScrollArea>
</>
);
}
@@ -1,5 +1,6 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
@@ -10,7 +11,6 @@ import {
IChangeSpaceMemberRole,
IRemoveSpaceMember,
ISpace,
ISpaceMember,
} from "@/features/space/types/space.types";
import {
addSpaceMember,
@@ -190,15 +190,19 @@ export function useDeleteSpaceMutation() {
});
}
export function useSpaceMembersQuery(
export function useSpaceMembersInfiniteQuery(
spaceId: string,
params?: QueryParams,
): UseQueryResult<IPagination<ISpaceMember>, Error> {
return useQuery({
queryKey: ["spaceMembers", spaceId, params],
queryFn: () => getSpaceMembers(spaceId, params),
query?: string,
) {
return useInfiniteQuery({
queryKey: ["spaceMembers", spaceId, query],
queryFn: ({ pageParam }) =>
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
enabled: !!spaceId,
placeholderData: keepPreviousData,
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
});
}
@@ -104,6 +104,7 @@ export class SpaceMemberRepo {
.leftJoin('users', 'users.id', 'spaceMembers.userId')
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
.select([
'spaceMembers.id as id',
'users.id as userId',
'users.name as userName',
'users.avatarUrl as userAvatarUrl',
@@ -120,6 +121,12 @@ export class SpaceMemberRepo {
'isGroup',
),
)
.select(
sql<number>`case "space_members"."role" when 'admin' then 1 when 'writer' then 2 when 'reader' then 3 else 4 end`.as(
'roleOrder',
),
)
.select(sql<string>`coalesce(users.name, groups.name)`.as('memberName'))
.where('spaceId', '=', spaceId);
if (pagination.query) {
@@ -149,12 +156,16 @@ export class SpaceMemberRepo {
cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor,
fields: [
{ expression: 'sub.roleOrder', direction: 'asc', key: 'roleOrder' },
{ expression: 'sub.isGroup', direction: 'desc', key: 'isGroup' },
{ expression: 'sub.createdAt', direction: 'asc', key: 'createdAt' },
{ expression: 'sub.memberName', direction: 'asc', key: 'memberName' },
{ expression: 'sub.id', direction: 'asc', key: 'id' },
],
parseCursor: (cursor) => ({
roleOrder: parseInt(cursor.roleOrder, 10),
isGroup: parseInt(cursor.isGroup, 10),
createdAt: new Date(cursor.createdAt),
memberName: cursor.memberName,
id: cursor.id,
}),
});