Skip to content

Commit cc6af4c

Browse files
atrakhConvex, Inc.
authored andcommitted
dashboard: paginate the project roles modal (#43917)
GitOrigin-RevId: af968137bb1a97deeccc7f08729f21d7d8b2af13
1 parent a4a8fe0 commit cc6af4c

File tree

5 files changed

+149
-56
lines changed

5 files changed

+149
-56
lines changed

npm-packages/dashboard/src/components/teamSettings/MemberProjectRolesModal.tsx

Lines changed: 130 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@ import { Button } from "@ui/Button";
22
import { Tooltip } from "@ui/Tooltip";
33
import { Checkbox } from "@ui/Checkbox";
44
import { Modal } from "@ui/Modal";
5+
import { TextInput } from "@ui/TextInput";
6+
import { LoadingLogo } from "@ui/Loading";
57
import difference from "lodash/difference";
6-
import React, { useState } from "react";
8+
import React, { useState, useEffect } from "react";
79
import type {
810
TeamResponse,
911
ProjectMemberRoleResponse,
@@ -13,20 +15,22 @@ import type {
1315
} from "generatedApi";
1416
import Link from "next/link";
1517
import { useHasProjectAdminPermissions } from "api/roles";
18+
import { usePaginatedProjects } from "api/projects";
1619
import sortBy from "lodash/sortBy";
1720
import { TeamMemberLink } from "elements/TeamMemberLink";
21+
import { PaginationControls } from "elements/PaginationControls";
22+
import { useDebounce } from "react-use";
23+
import { useGlobalLocalStorage } from "@common/lib/useGlobalLocalStorage";
1824
import { ProjectLink } from "./AuditLogItem";
1925

2026
export function MemberProjectRolesModal({
2127
team,
22-
projects,
2328
member,
2429
projectRoles,
2530
onUpdateProjectRoles,
2631
onClose,
2732
}: {
2833
team: TeamResponse;
29-
projects: ProjectDetails[];
3034
member: TeamMemberResponse;
3135
projectRoles: ProjectMemberRoleResponse[];
3236
onUpdateProjectRoles: (body: UpdateProjectRolesArgs) => Promise<undefined>;
@@ -42,6 +46,73 @@ export function MemberProjectRolesModal({
4246

4347
const [isSaving, setIsSaving] = useState(false);
4448

49+
// Pagination and search state
50+
const [projectQuery, setProjectQuery] = useState("");
51+
const [debouncedQuery, setDebouncedQuery] = useState("");
52+
const [currentCursor, setCurrentCursor] = useState<string | undefined>(
53+
undefined,
54+
);
55+
const [cursorHistory, setCursorHistory] = useState<(string | undefined)[]>([
56+
undefined,
57+
]);
58+
const [pageSize, setPageSize] = useGlobalLocalStorage("projectsPageSize", 25);
59+
60+
// Debounce search query (300ms delay)
61+
useDebounce(
62+
() => {
63+
setDebouncedQuery(projectQuery);
64+
},
65+
300,
66+
[projectQuery],
67+
);
68+
69+
// Fetch paginated projects with debounced query
70+
const paginatedData = usePaginatedProjects(
71+
team.id,
72+
{
73+
cursor: currentCursor,
74+
q: debouncedQuery.trim() || undefined,
75+
},
76+
30000,
77+
);
78+
79+
const projects = paginatedData?.items ?? [];
80+
const hasMore = paginatedData?.pagination.hasMore ?? false;
81+
const nextCursor = paginatedData?.pagination.nextCursor;
82+
const isLoading = paginatedData === undefined;
83+
84+
// Calculate current page range for display
85+
const currentPageNumber = cursorHistory.length;
86+
87+
const handleNextPage = () => {
88+
if (nextCursor) {
89+
setCursorHistory((prev) => [...prev, currentCursor]);
90+
setCurrentCursor(nextCursor);
91+
}
92+
};
93+
94+
const handlePrevPage = () => {
95+
if (cursorHistory.length > 1) {
96+
const newHistory = [...cursorHistory];
97+
newHistory.pop();
98+
setCursorHistory(newHistory);
99+
setCurrentCursor(newHistory[newHistory.length - 1]);
100+
}
101+
};
102+
103+
const handlePageSizeChange = (newPageSize: number) => {
104+
setPageSize(newPageSize);
105+
// Reset to first page when page size changes
106+
setCurrentCursor(undefined);
107+
setCursorHistory([undefined]);
108+
};
109+
110+
// Reset cursor when debounced search query changes
111+
useEffect(() => {
112+
setCurrentCursor(undefined);
113+
setCursorHistory([undefined]);
114+
}, [debouncedQuery]);
115+
45116
const closeWithConfirmation = () => {
46117
if (addedProjects.length > 0 || removedProjects.length > 0) {
47118
// eslint-disable-next-line no-alert
@@ -101,9 +172,43 @@ export function MemberProjectRolesModal({
101172
}
102173
}}
103174
>
104-
<div className="scrollbar max-h-[60vh] overflow-auto">
105-
{sortBy(projects, (project) => project.name.toLocaleLowerCase()).map(
106-
(project) => (
175+
{/* Search input */}
176+
<TextInput
177+
placeholder="Search projects"
178+
value={projectQuery}
179+
onChange={(e) => setProjectQuery(e.target.value)}
180+
type="search"
181+
id="Search projects in modal"
182+
isSearchLoading={isLoading && debouncedQuery === projectQuery}
183+
/>
184+
185+
{/* Loading state */}
186+
{projects.length === 0 && isLoading && (
187+
<div className="my-12 flex flex-col items-center gap-2">
188+
<LoadingLogo />
189+
</div>
190+
)}
191+
192+
{/* Empty search results */}
193+
{projects.length === 0 && !isLoading && debouncedQuery.trim() && (
194+
<div className="my-12 flex animate-fadeInFromLoading flex-col items-center gap-2 text-content-secondary">
195+
No projects match your search.
196+
</div>
197+
)}
198+
199+
{/* Empty state - no projects */}
200+
{projects.length === 0 && !isLoading && !debouncedQuery.trim() && (
201+
<div className="my-12 flex flex-col items-center gap-2 text-content-secondary">
202+
This team doesn't have any projects yet.
203+
</div>
204+
)}
205+
206+
{/* Project list */}
207+
{projects.length > 0 && (
208+
<div className="scrollbar max-h-[40vh] overflow-auto">
209+
{sortBy(projects, (project) =>
210+
project.name.toLocaleLowerCase(),
211+
).map((project) => (
107212
<ProjectRoleItem
108213
key={project.id}
109214
project={project}
@@ -112,10 +217,25 @@ export function MemberProjectRolesModal({
112217
newProjectRoles={newProjectRoles}
113218
setNewProjectRoles={setNewProjectRoles}
114219
/>
115-
),
116-
)}
117-
</div>
118-
<p className="text-xs text-content-secondary">
220+
))}
221+
</div>
222+
)}
223+
224+
{/* Bottom pagination controls with page size */}
225+
{projects.length > 0 && (
226+
<PaginationControls
227+
showPageSize
228+
isCursorBasedPagination
229+
currentPage={currentPageNumber}
230+
hasMore={hasMore}
231+
pageSize={pageSize}
232+
onPageSizeChange={handlePageSizeChange}
233+
onPreviousPage={handlePrevPage}
234+
onNextPage={handleNextPage}
235+
canGoPrevious={cursorHistory.length > 1}
236+
/>
237+
)}
238+
<p className="mt-1 text-xs text-content-secondary">
119239
Pro-tip! You can manage the Project Admin role for multiple members at
120240
the same time on the{" "}
121241
<Link

npm-packages/dashboard/src/components/teamSettings/TeamMemberList.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { Sheet } from "@ui/Sheet";
88
import { LoadingTransition } from "@ui/Loading";
99
import { TextInput } from "@ui/TextInput";
1010
import { useState } from "react";
11-
import { useProjects } from "api/projects";
1211
import {
1312
useUpdateTeamMemberRole,
1413
useIsCurrentMemberTeamAdmin,
@@ -59,7 +58,6 @@ export function TeamMemberList({
5958
const hasAdminPermissions = useIsCurrentMemberTeamAdmin();
6059

6160
const { projectRoles } = useProjectRoles();
62-
const projects = useProjects(team.id);
6361

6462
const updateProjectRoles = useUpdateProjectRoles(team.id);
6563

@@ -79,13 +77,12 @@ export function TeamMemberList({
7977
</div>
8078
</div>
8179
<LoadingTransition>
82-
{profile && members && projectRoles && projects && (
80+
{profile && members && projectRoles && (
8381
<div className="flex w-full flex-col">
8482
{/* Always show self at the top */}
8583
{me && (
8684
<TeamMemberListItem
8785
team={team}
88-
projects={projects}
8986
member={me}
9087
members={members}
9188
canChangeRole={false}
@@ -112,7 +109,6 @@ export function TeamMemberList({
112109
<TeamMemberListItem
113110
key={`member${member.id}`}
114111
team={team}
115-
projects={projects}
116112
member={member}
117113
members={members}
118114
canChangeRole

npm-packages/dashboard/src/components/teamSettings/TeamMemberListItem.test.tsx

Lines changed: 10 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,25 @@
11
import { render, screen, waitFor } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
3-
import {
4-
TeamResponse,
5-
ProjectDetails,
6-
MemberResponse,
7-
TeamMemberResponse,
8-
} from "generatedApi";
3+
import { TeamResponse, MemberResponse, TeamMemberResponse } from "generatedApi";
94
import { TeamMemberListItem } from "./TeamMemberListItem";
105

116
jest.mock("next/router", () => jest.requireActual("next-router-mock"));
127
jest.mock("api/profile", () => {});
138
jest.mock("api/backups", () => {});
149
jest.mock("api/teams", () => {});
15-
jest.mock("api/projects", () => {});
10+
jest.mock("api/projects", () => ({
11+
usePaginatedProjects: jest.fn().mockReturnValue({
12+
items: [],
13+
pagination: { hasMore: false, nextCursor: undefined },
14+
}),
15+
}));
1616
jest.mock("api/deployments", () => {});
1717
jest.mock("api/roles", () => ({
1818
useHasProjectAdminPermissions: jest.fn(),
1919
}));
20+
jest.mock("@common/lib/useGlobalLocalStorage", () => ({
21+
useGlobalLocalStorage: jest.fn().mockReturnValue([25, jest.fn()]),
22+
}));
2023

2124
describe("TeamMemberListItem", () => {
2225
const team: TeamResponse = {
@@ -27,24 +30,6 @@ describe("TeamMemberListItem", () => {
2730
suspended: false,
2831
referralCode: "CODE123",
2932
};
30-
const projects: ProjectDetails[] = [
31-
{
32-
id: 1,
33-
name: "Project 1",
34-
slug: "project-1",
35-
teamId: 1,
36-
isDemo: false,
37-
createTime: 0,
38-
},
39-
{
40-
id: 2,
41-
name: "Project 2",
42-
slug: "project-2",
43-
teamId: 1,
44-
isDemo: false,
45-
createTime: 0,
46-
},
47-
];
4833
const myProfile: MemberResponse = {
4934
id: 1,
5035
name: "John Doe",
@@ -76,7 +61,6 @@ describe("TeamMemberListItem", () => {
7661
<TeamMemberListItem
7762
team={team}
7863
myProfile={myProfile}
79-
projects={projects}
8064
projectRoles={[]}
8165
onUpdateProjectRoles={jest.fn()}
8266
member={member}
@@ -96,7 +80,6 @@ describe("TeamMemberListItem", () => {
9680
<TeamMemberListItem
9781
team={team}
9882
myProfile={myProfile}
99-
projects={projects}
10083
projectRoles={[]}
10184
onUpdateProjectRoles={jest.fn()}
10285
member={member}
@@ -119,7 +102,6 @@ describe("TeamMemberListItem", () => {
119102
<TeamMemberListItem
120103
team={team}
121104
myProfile={myProfile}
122-
projects={projects}
123105
projectRoles={[]}
124106
onUpdateProjectRoles={jest.fn()}
125107
member={member}
@@ -142,7 +124,6 @@ describe("TeamMemberListItem", () => {
142124
<TeamMemberListItem
143125
team={team}
144126
myProfile={myProfile}
145-
projects={projects}
146127
projectRoles={[]}
147128
onUpdateProjectRoles={jest.fn()}
148129
member={member}
@@ -173,7 +154,6 @@ describe("TeamMemberListItem", () => {
173154
<TeamMemberListItem
174155
team={team}
175156
myProfile={myProfile}
176-
projects={projects}
177157
projectRoles={[]}
178158
onUpdateProjectRoles={jest.fn()}
179159
member={member}
@@ -193,7 +173,6 @@ describe("TeamMemberListItem", () => {
193173
<TeamMemberListItem
194174
team={team}
195175
myProfile={myProfile}
196-
projects={projects}
197176
projectRoles={[]}
198177
onUpdateProjectRoles={jest.fn()}
199178
member={member}
@@ -221,7 +200,6 @@ describe("TeamMemberListItem", () => {
221200
<TeamMemberListItem
222201
team={team}
223202
myProfile={member}
224-
projects={projects}
225203
projectRoles={[]}
226204
onUpdateProjectRoles={jest.fn()}
227205
member={member}

npm-packages/dashboard/src/components/teamSettings/TeamMemberListItem.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type {
22
MemberResponse,
3-
ProjectDetails,
43
ProjectMemberRoleResponse,
54
UpdateProjectRolesArgs,
65
TeamResponse,
@@ -39,7 +38,6 @@ type TeamMemberListItemProps = {
3938
onUpdateProjectRoles: (body: UpdateProjectRolesArgs) => Promise<undefined>;
4039
hasAdminPermissions: boolean;
4140
projectRoles: ProjectMemberRoleResponse[];
42-
projects: ProjectDetails[];
4341
};
4442
export function TeamMemberListItem({
4543
team,
@@ -52,7 +50,6 @@ export function TeamMemberListItem({
5250
onRemoveMember,
5351
hasAdminPermissions,
5452
projectRoles,
55-
projects,
5653
}: TeamMemberListItemProps) {
5754
const router = useRouter();
5855
const isMemberTheLastAdmin =
@@ -224,7 +221,6 @@ export function TeamMemberListItem({
224221
<MemberProjectRolesModal
225222
member={member}
226223
team={team}
227-
projects={projects}
228224
projectRoles={projectRoles}
229225
onClose={() => setShowProjectRolesModal(false)}
230226
onUpdateProjectRoles={onUpdateProjectRoles}

0 commit comments

Comments
 (0)