Skip to content

Commit

Permalink
Paginate members and make them searchable
Browse files Browse the repository at this point in the history
  • Loading branch information
xixixao committed Jan 26, 2024
1 parent 35b6f0f commit 7c3fd09
Show file tree
Hide file tree
Showing 8 changed files with 252 additions and 130 deletions.
20 changes: 18 additions & 2 deletions app/t/[teamSlug]/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { api } from "@/convex/_generated/api";
import { useQuery } from "convex/react";
import { UsePaginatedQueryResult, useQuery } from "convex/react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { useEffect, useRef } from "react";

export function useCurrentTeam() {
const router = useRouter();
Expand All @@ -27,3 +27,19 @@ export function useViewerPermissions() {
});
return permissions == null ? null : new Set(permissions);
}

export function useStaleValue<T>(value: T | undefined) {
const stored = useRef(value);
if (value !== undefined) {
stored.current = value;
}
return { value: stored.current, stale: value !== stored.current };
}

export function useStalePaginationValue<T>(value: UsePaginatedQueryResult<T>) {
const stored = useRef(value);
if (value.results.length > 0 || !value.isLoading) {
stored.current = value;
}
return { value: stored.current, stale: value !== stored.current };
}
268 changes: 161 additions & 107 deletions app/t/[teamSlug]/settings/members/MemberList.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { useCurrentTeam, useViewerPermissions } from "@/app/t/[teamSlug]/hooks";
import {
useCurrentTeam,
useStalePaginationValue,
useViewerPermissions,
} from "@/app/t/[teamSlug]/hooks";
import { SelectRole } from "@/app/t/[teamSlug]/settings/members/SelectRole";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
Expand All @@ -8,34 +12,50 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import { Skeleton } from "@/components/ui/skeleton";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "@/components/ui/use-toast";
import { api } from "@/convex/_generated/api";
import { DotsHorizontalIcon } from "@radix-ui/react-icons";
import { useMutation, useQuery } from "convex/react";
import {
UsePaginatedQueryReturnType,
useMutation,
usePaginatedQuery,
useQuery,
} from "convex/react";
import { FunctionReturnType } from "convex/server";
import { ConvexError } from "convex/values";
import { useState } from "react";

export function MembersList() {
const team = useCurrentTeam();
const viewerPermissions = useViewerPermissions();
const [search, setSearch] = useState("");

const members = useQuery(api.users.teams.members.list, {
teamId: team?._id,
});
const members = usePaginatedQuery(
api.users.teams.members.list,
team === undefined ? "skip" : { teamId: team._id, search },
{ initialNumItems: 40 }
);
const invites = useQuery(api.users.teams.members.invites.list, {
teamId: team?._id,
});

if (
team == null ||
members == null ||
viewerPermissions == null ||
invites == null
) {
if (team == null || viewerPermissions == null || invites == null) {
return null;
}
const searchInput = (
<Input
className="my-2"
placeholder="Filter..."
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
/>
);
return (
<Card>
<CardContent>
Expand All @@ -45,14 +65,17 @@ export function MembersList() {
<TabsTrigger value="invites">Pending Invites</TabsTrigger>
</TabsList>
<TabsContent value="members">
{searchInput}
<MembersTable
members={members}
viewerPermissions={viewerPermissions}
/>
</TabsContent>
<TabsContent value="invites">
{searchInput}
<InvitesTable
invites={invites}
search={search}
viewerPermissions={viewerPermissions}
/>
</TabsContent>
Expand All @@ -66,88 +89,117 @@ function MembersTable({
members,
viewerPermissions,
}: {
members: NonNullable<FunctionReturnType<typeof api.users.teams.members.list>>;
members: UsePaginatedQueryReturnType<typeof api.users.teams.members.list>;
viewerPermissions: NonNullable<ReturnType<typeof useViewerPermissions>>;
}) {
const updateMember = useMutation(api.users.teams.members.update);
const deleteMember = useMutation(api.users.teams.members.deleteMember);
const hasManagePermission = viewerPermissions.has("Manage Members");
const {
value: { results, isLoading, status },
stale,
} = useStalePaginationValue(members);
return (
<Table>
<TableBody>
{members.map((member) => (
<TableRow key={member._id}>
<TableCell>
<div className="flex flex-col">
<div className="font-medium">{member.fullName}</div>
<div className="text-muted-foreground">{member.email}</div>
</div>
</TableCell>
<TableCell>
<div className="flex justify-end">
<SelectRole
disabled={!hasManagePermission}
value={member.roleId}
onChange={(roleId) => {
updateMember({ memberId: member._id, roleId }).catch(
(error) => {
toast({
title:
error instanceof ConvexError
? error.data
: "Could not update role",
variant: "destructive",
});
}
);
}}
/>
</div>
</TableCell>
<TableCell width={10}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
<div className="flex flex-col">
<Table>
<TableBody className={stale ? "animate-pulse" : ""}>
{results.map((member) => (
<TableRow key={member._id}>
<TableCell>
<div className="flex flex-col">
<div className="font-medium">{member.fullName}</div>
<div className="text-muted-foreground">{member.email}</div>
</div>
</TableCell>
<TableCell>
<div className="flex justify-end">
<SelectRole
disabled={!hasManagePermission}
variant="ghost"
size="icon"
>
<DotsHorizontalIcon className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
onSelect={() => {
deleteMember({ memberId: member._id }).catch((error) => {
toast({
title:
error instanceof ConvexError
? error.data
: "Could not delete member",
variant: "destructive",
});
});
value={member.roleId}
onChange={(roleId) => {
updateMember({ memberId: member._id, roleId }).catch(
(error) => {
toast({
title:
error instanceof ConvexError
? error.data
: "Could not update role",
variant: "destructive",
});
}
);
}}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
/>
</div>
</TableCell>
<TableCell width={10}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={!hasManagePermission}
variant="ghost"
size="icon"
>
<DotsHorizontalIcon className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
onSelect={() => {
deleteMember({ memberId: member._id }).catch(
(error) => {
toast({
title:
error instanceof ConvexError
? error.data
: "Could not delete member",
variant: "destructive",
});
}
);
}}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{isLoading ? (
<Skeleton className="h-8 animate-pulse" />
) : status === "Exhausted" ? (
results.length === 0 ? (
<div className="text-muted-foreground text-sm py-2 text-center">
No results found
</div>
) : null
) : (
<Button
className="mt-4"
variant="secondary"
onClick={() => {
members.loadMore(40);
}}
>
Load more
</Button>
)}
</div>
);
}

function InvitesTable({
invites,
search,
viewerPermissions,
}: {
invites: NonNullable<
FunctionReturnType<typeof api.users.teams.members.invites.list>
>;
search: string;
viewerPermissions: NonNullable<ReturnType<typeof useViewerPermissions>>;
}) {
const deleteInvite = useMutation(
Expand All @@ -166,38 +218,40 @@ function InvitesTable({
return (
<Table>
<TableBody>
{invites.map((invite) => (
<TableRow key={invite._id}>
<TableCell>
<div className="font-medium">{invite.email}</div>
</TableCell>
<TableCell>
<div className="flex justify-end">{invite.role}</div>
</TableCell>
<TableCell width={10}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={!hasManagePermission}
variant="ghost"
size="icon"
>
<DotsHorizontalIcon className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
onSelect={() => {
void deleteInvite({ inviteId: invite._id });
}}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
{invites
.filter((invite) => invite.email.includes(search))
.map((invite) => (
<TableRow key={invite._id}>
<TableCell>
<div className="font-medium">{invite.email}</div>
</TableCell>
<TableCell>
<div className="flex justify-end">{invite.role}</div>
</TableCell>
<TableCell width={10}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
disabled={!hasManagePermission}
variant="ghost"
size="icon"
>
<DotsHorizontalIcon className="w-5 h-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent side="bottom" align="end">
<DropdownMenuItem
onSelect={() => {
void deleteInvite({ inviteId: invite._id });
}}
>
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
Expand Down
5 changes: 3 additions & 2 deletions convex/invites.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { v } from "convex/values";
import { mutation, query } from "./functions";
import { Ent, QueryCtx } from "./types";
import { createMember } from "./users/teams/members";

export const list = query({
args: {},
Expand Down Expand Up @@ -58,10 +59,10 @@ export const accept = mutation({
roleId: invite.roleId,
});
} else {
await ctx.table("members").insert({
await createMember(ctx, {
teamId: invite.teamId,
userId: ctx.viewerX()._id,
roleId: invite.roleId,
user: ctx.viewerX(),
});
}
await invite.delete();
Expand Down
Loading

0 comments on commit 7c3fd09

Please sign in to comment.