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 { import {
Center,
Group, Group,
Loader,
Table, Table,
Text, Text,
Menu, Menu,
ActionIcon, ActionIcon,
ScrollArea, ScrollArea,
} from "@mantine/core"; } from "@mantine/core";
import React from "react"; import React, { useCallback, useEffect, useRef, 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 { import {
useChangeSpaceMemberRoleMutation, useChangeSpaceMemberRoleMutation,
useRemoveSpaceMemberMutation, useRemoveSpaceMemberMutation,
useSpaceMembersQuery, useSpaceMembersInfiniteQuery,
} from "@/features/space/queries/space-query.ts"; } from "@/features/space/queries/space-query.ts";
import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx"; import { IconGroupCircle } from "@/components/icons/icon-people-circle.tsx";
import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts"; import { IRemoveSpaceMember } from "@/features/space/types/space.types.ts";
@@ -24,9 +26,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";
import { SearchInput } from "@/components/common/search-input.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"; import { AutoTooltipText } from "@/components/ui/auto-tooltip-text.tsx";
type MemberType = "user" | "group"; type MemberType = "user" | "group";
@@ -41,12 +41,32 @@ export default function SpaceMembersList({
readOnly, readOnly,
}: SpaceMembersProps) { }: SpaceMembersProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { search, cursor, goNext, goPrev, handleSearch } = usePaginateAndSearch(); const [search, setSearch] = useState("");
const { data, isLoading } = useSpaceMembersQuery(spaceId, { const handleSearch = useCallback((query: string) => setSearch(query), []);
cursor,
limit: 100, const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
query: search, 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 removeSpaceMember = useRemoveSpaceMemberMutation();
const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation(); const changeSpaceMemberRoleMutation = useChangeSpaceMemberRoleMutation();
@@ -111,10 +131,12 @@ export default function SpaceMembersList({
onConfirm: () => onRemove(memberId, type), onConfirm: () => onRemove(memberId, type),
}); });
const members = data?.pages.flatMap((page) => page.items) ?? [];
return ( return (
<> <>
<SearchInput onSearch={handleSearch} /> <SearchInput onSearch={handleSearch} />
<ScrollArea h={450}> <ScrollArea h={450} viewportRef={viewportRef}>
<Table.ScrollContainer minWidth={500}> <Table.ScrollContainer minWidth={500}>
<Table highlightOnHover verticalSpacing={8}> <Table highlightOnHover verticalSpacing={8}>
<Table.Thead> <Table.Thead>
@@ -126,7 +148,7 @@ export default function SpaceMembersList({
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{data?.items.map((member, index) => ( {members.map((member, index) => (
<Table.Tr key={index}> <Table.Tr key={index}>
<Table.Td> <Table.Td>
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
@@ -154,6 +176,11 @@ export default function SpaceMembersList({
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
{readOnly ? (
<Text fz="sm">
{t(getSpaceRoleLabel(member.role))}
</Text>
) : (
<RoleSelectMenu <RoleSelectMenu
roles={spaceRoleData} roles={spaceRoleData}
roleName={getSpaceRoleLabel(member.role)} roleName={getSpaceRoleLabel(member.role)}
@@ -165,8 +192,8 @@ export default function SpaceMembersList({
member.role, member.role,
) )
} }
disabled={readOnly}
/> />
)}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
@@ -202,16 +229,15 @@ export default function SpaceMembersList({
</Table.Tbody> </Table.Tbody>
</Table> </Table>
</Table.ScrollContainer> </Table.ScrollContainer>
</ScrollArea>
{data?.items.length > 0 && ( <div ref={sentinelRef} style={{ height: 1 }} />
<Paginate
hasPrevPage={data?.meta?.hasPrevPage} {isFetchingNextPage && (
hasNextPage={data?.meta?.hasNextPage} <Center py="xs">
onNext={() => goNext(data?.meta?.nextCursor)} <Loader size="xs" />
onPrev={goPrev} </Center>
/>
)} )}
</ScrollArea>
</> </>
); );
} }
@@ -1,5 +1,6 @@
import { import {
keepPreviousData, keepPreviousData,
useInfiniteQuery,
useMutation, useMutation,
useQuery, useQuery,
useQueryClient, useQueryClient,
@@ -10,7 +11,6 @@ import {
IChangeSpaceMemberRole, IChangeSpaceMemberRole,
IRemoveSpaceMember, IRemoveSpaceMember,
ISpace, ISpace,
ISpaceMember,
} from "@/features/space/types/space.types"; } from "@/features/space/types/space.types";
import { import {
addSpaceMember, addSpaceMember,
@@ -190,15 +190,19 @@ export function useDeleteSpaceMutation() {
}); });
} }
export function useSpaceMembersQuery( export function useSpaceMembersInfiniteQuery(
spaceId: string, spaceId: string,
params?: QueryParams, query?: string,
): UseQueryResult<IPagination<ISpaceMember>, Error> { ) {
return useQuery({ return useInfiniteQuery({
queryKey: ["spaceMembers", spaceId, params], queryKey: ["spaceMembers", spaceId, query],
queryFn: () => getSpaceMembers(spaceId, params), queryFn: ({ pageParam }) =>
getSpaceMembers(spaceId, { cursor: pageParam, limit: 50, query }),
enabled: !!spaceId, enabled: !!spaceId,
placeholderData: keepPreviousData, 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('users', 'users.id', 'spaceMembers.userId')
.leftJoin('groups', 'groups.id', 'spaceMembers.groupId') .leftJoin('groups', 'groups.id', 'spaceMembers.groupId')
.select([ .select([
'spaceMembers.id as id',
'users.id as userId', 'users.id as userId',
'users.name as userName', 'users.name as userName',
'users.avatarUrl as userAvatarUrl', 'users.avatarUrl as userAvatarUrl',
@@ -120,6 +121,12 @@ export class SpaceMemberRepo {
'isGroup', '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); .where('spaceId', '=', spaceId);
if (pagination.query) { if (pagination.query) {
@@ -149,12 +156,16 @@ export class SpaceMemberRepo {
cursor: pagination.cursor, cursor: pagination.cursor,
beforeCursor: pagination.beforeCursor, beforeCursor: pagination.beforeCursor,
fields: [ fields: [
{ expression: 'sub.roleOrder', direction: 'asc', key: 'roleOrder' },
{ expression: 'sub.isGroup', direction: 'desc', key: 'isGroup' }, { 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) => ({ parseCursor: (cursor) => ({
roleOrder: parseInt(cursor.roleOrder, 10),
isGroup: parseInt(cursor.isGroup, 10), isGroup: parseInt(cursor.isGroup, 10),
createdAt: new Date(cursor.createdAt), memberName: cursor.memberName,
id: cursor.id,
}), }),
}); });