Skip to content

Commit f76bd6a

Browse files
Batch Repo addition to a team(UI) (#674)
* feat: Add batch import functionality and pagination to repository selection * feat: Implement Batch Import Modal for repository selection * add request cancellation to git_orgs_repo Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * add accessibility improvements to pagination Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * add accessibility improvements to pagination Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 005aba7 commit f76bd6a

File tree

2 files changed

+470
-79
lines changed

2 files changed

+470
-79
lines changed
Lines changed: 335 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,335 @@
1+
import { MoreVert as MoreVertIcon } from '@mui/icons-material';
2+
import {
3+
Button,
4+
FormControl,
5+
InputLabel,
6+
Select,
7+
MenuItem,
8+
TextField,
9+
Box,
10+
Checkbox,
11+
Table,
12+
TableBody,
13+
TableCell,
14+
TableHead,
15+
TableRow,
16+
TableContainer,
17+
Paper,
18+
Alert,
19+
Pagination,
20+
Chip,
21+
IconButton,
22+
Menu,
23+
MenuList,
24+
ListItemText
25+
} from '@mui/material';
26+
import axios from 'axios';
27+
import { useSnackbar } from 'notistack';
28+
import { FC, useEffect, useState, useRef } from 'react';
29+
30+
import { Integration } from '@/constants/integrations';
31+
import { useAuth } from '@/hooks/useAuth';
32+
import { BaseRepo } from '@/types/resources';
33+
34+
import { FlexBox } from '../FlexBox';
35+
36+
export interface BatchImportModalProps {
37+
onClose: () => void;
38+
onAdd: (repos: BaseRepo[]) => void;
39+
existing: BaseRepo[];
40+
}
41+
42+
const PAGE_SIZE = 50;
43+
44+
interface PageData {
45+
repos: BaseRepo[];
46+
endCursor: string | null;
47+
hasNextPage: boolean;
48+
}
49+
50+
export const BatchImportModal: FC<BatchImportModalProps> = ({
51+
onClose,
52+
onAdd,
53+
existing
54+
}) => {
55+
const { orgId } = useAuth();
56+
const { enqueueSnackbar } = useSnackbar();
57+
58+
const [provider, setProvider] = useState<Integration>(Integration.GITHUB);
59+
const [orgName, setOrgName] = useState<string>('');
60+
const [pages, setPages] = useState<Record<number, PageData>>({});
61+
const [currentPage, setCurrentPage] = useState(1);
62+
const [filtered, setFiltered] = useState<BaseRepo[]>([]);
63+
const [selected, setSelected] = useState<BaseRepo[]>([...existing]);
64+
const [loadingPage, setLoadingPage] = useState(false);
65+
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
66+
67+
const didMountRef = useRef(false);
68+
69+
useEffect(() => {
70+
if (!didMountRef.current) {
71+
setSelected([...existing]);
72+
didMountRef.current = true;
73+
}
74+
}, [existing]);
75+
76+
const fetchPage = async (pageNum: number) => {
77+
// allow cancelling the request to avoid race conditions / setState on unmounted
78+
const controller = new AbortController();
79+
80+
if (pages[pageNum]) {
81+
setFiltered(pages[pageNum].repos);
82+
setCurrentPage(pageNum);
83+
return;
84+
}
85+
86+
const prev = pages[pageNum - 1];
87+
const params: any = { provider, org: orgName, first: PAGE_SIZE };
88+
if (prev?.endCursor) {
89+
params.after = prev.endCursor;
90+
}
91+
92+
setLoadingPage(true);
93+
try {
94+
const resp = await axios.get(`/api/internal/${orgId}/git_org_repos`, {
95+
params,
96+
signal: controller.signal
97+
});
98+
const { repos, pageInfo } = resp.data;
99+
const pageData: PageData = {
100+
repos,
101+
endCursor: pageInfo.endCursor,
102+
hasNextPage: pageInfo.hasNextPage
103+
};
104+
setPages((p) => ({ ...p, [pageNum]: pageData }));
105+
setFiltered(repos);
106+
setCurrentPage(pageNum);
107+
} catch (e: any) {
108+
// ignore aborts, but report other errors
109+
if (e.name !== 'AbortError') {
110+
console.error(e);
111+
enqueueSnackbar('Failed to load page', { variant: 'error' });
112+
}
113+
} finally {
114+
setLoadingPage(false);
115+
}
116+
117+
// expose a cleanup to abort this request if needed
118+
return () => controller.abort();
119+
};
120+
121+
const fetchAll = async (): Promise<BaseRepo[]> => {
122+
setLoadingPage(true);
123+
try {
124+
const resp = await axios.get(`/api/internal/${orgId}/git_org_repos`, {
125+
params: { provider, org: orgName, select_all: true }
126+
});
127+
return resp.data.repos as BaseRepo[];
128+
} catch {
129+
enqueueSnackbar('Failed to load all repos', { variant: 'error' });
130+
return [];
131+
} finally {
132+
setLoadingPage(false);
133+
}
134+
};
135+
136+
const handleFilter = (q: string) => {
137+
const lower = q.toLowerCase();
138+
const current = pages[currentPage]?.repos || [];
139+
setFiltered(
140+
current.filter((r) =>
141+
`${r.parent}/${r.name}`.toLowerCase().includes(lower)
142+
)
143+
);
144+
};
145+
146+
const toggleOne = (repo: BaseRepo) => {
147+
setSelected((sel) =>
148+
sel.some((r) => r.id === repo.id)
149+
? sel.filter((r) => r.id !== repo.id)
150+
: [...sel, repo]
151+
);
152+
};
153+
154+
const visible = filtered;
155+
const openMenu = (e: React.MouseEvent<HTMLElement>) =>
156+
setAnchorEl(e.currentTarget);
157+
const closeMenu = () => setAnchorEl(null);
158+
159+
const selectVisible = () => {
160+
setSelected((sel) => [
161+
...sel,
162+
...visible.filter((r) => !sel.some((x) => x.id === r.id))
163+
]);
164+
closeMenu();
165+
};
166+
const deselectVisible = () => {
167+
const visIds = new Set(visible.map((r) => r.id));
168+
setSelected((sel) => sel.filter((r) => !visIds.has(r.id)));
169+
closeMenu();
170+
};
171+
const selectEverything = async () => {
172+
const all = await fetchAll();
173+
setSelected((sel) => {
174+
const map = new Map<number, BaseRepo>();
175+
[...sel, ...all].forEach((r) => map.set(r.id as number, r));
176+
return Array.from(map.values());
177+
});
178+
closeMenu();
179+
};
180+
181+
const handleAdd = () => {
182+
onAdd(selected);
183+
onClose();
184+
};
185+
186+
return (
187+
<FlexBox col gap={2} p={3} maxWidth="900px" bgcolor="background.paper">
188+
<Box fontSize="h6.fontSize" mb={1}>
189+
Batch Import Repositories
190+
</Box>
191+
192+
<Box display="flex" gap={2}>
193+
<FormControl sx={{ minWidth: 140 }}>
194+
<InputLabel>Provider</InputLabel>
195+
<Select
196+
value={provider}
197+
label="Provider"
198+
onChange={(e) => setProvider(e.target.value as Integration)}
199+
>
200+
<MenuItem value={Integration.GITHUB}>GitHub</MenuItem>
201+
<MenuItem value={Integration.GITLAB}>GitLab</MenuItem>
202+
</Select>
203+
</FormControl>
204+
205+
<TextField
206+
label="Organization"
207+
placeholder="e.g. my-org"
208+
value={orgName}
209+
onChange={(e) => setOrgName(e.target.value)}
210+
fullWidth
211+
/>
212+
213+
<Button
214+
variant="contained"
215+
onClick={() => fetchPage(1)}
216+
disabled={loadingPage || !orgName.trim()}
217+
>
218+
Search
219+
</Button>
220+
</Box>
221+
222+
{selected.length > 0 && (
223+
<Box
224+
sx={{
225+
display: 'flex',
226+
flexWrap: 'wrap',
227+
overflowY: 'auto',
228+
maxHeight: 150,
229+
p: 1,
230+
gap: 1,
231+
// optional scrollbar styling:
232+
'&::-webkit-scrollbar': { width: 6 },
233+
'&::-webkit-scrollbar-thumb': {
234+
borderRadius: 3,
235+
backgroundColor: 'rgba(255,255,255,0.3)'
236+
}
237+
}}
238+
>
239+
{selected.map((r) => (
240+
<Chip
241+
key={r.id}
242+
label={`${r.parent}/${r.name}`}
243+
onDelete={() => toggleOne(r)}
244+
/>
245+
))}
246+
</Box>
247+
)}
248+
249+
{visible.length > 0 && (
250+
<>
251+
<Box display="flex" alignItems="center" gap={1}>
252+
<TextField
253+
placeholder="Filter current page"
254+
size="small"
255+
onChange={(e) => handleFilter(e.target.value)}
256+
fullWidth
257+
/>
258+
<IconButton size="small" onClick={openMenu}>
259+
<MoreVertIcon />
260+
</IconButton>
261+
<Menu anchorEl={anchorEl} open={!!anchorEl} onClose={closeMenu}>
262+
<MenuList>
263+
<MenuItem onClick={selectVisible}>
264+
<ListItemText primary="Select current page" />
265+
</MenuItem>
266+
<MenuItem onClick={selectEverything}>
267+
<ListItemText primary="Select all repos" />
268+
</MenuItem>
269+
<MenuItem onClick={deselectVisible}>
270+
<ListItemText primary="Deselect current page" />
271+
</MenuItem>
272+
</MenuList>
273+
</Menu>
274+
</Box>
275+
276+
{selected.length > 10 && (
277+
<Alert severity="warning">
278+
You’ve selected {selected.length} repositories. Initial sync may
279+
take longer for large batches.
280+
</Alert>
281+
)}
282+
283+
<TableContainer component={Paper} sx={{ maxHeight: 400 }}>
284+
<Table stickyHeader size="small">
285+
<TableHead>
286+
<TableRow>
287+
<TableCell padding="checkbox" />
288+
<TableCell>Repository</TableCell>
289+
</TableRow>
290+
</TableHead>
291+
<TableBody>
292+
{visible.map((repo) => (
293+
<TableRow key={repo.id} hover>
294+
<TableCell padding="checkbox">
295+
<Checkbox
296+
checked={selected.some((r) => r.id === repo.id)}
297+
onChange={() => toggleOne(repo)}
298+
/>
299+
</TableCell>
300+
<TableCell>
301+
{repo.parent}/{repo.name}
302+
</TableCell>
303+
</TableRow>
304+
))}
305+
</TableBody>
306+
</Table>
307+
</TableContainer>
308+
309+
<Box display="flex" justifyContent="center" mt={1}>
310+
<Pagination
311+
count={
312+
pages[currentPage]?.hasNextPage ? currentPage + 1 : currentPage
313+
}
314+
page={currentPage}
315+
onChange={(_, p) => fetchPage(p)}
316+
size="small"
317+
disabled={loadingPage}
318+
/>
319+
</Box>
320+
</>
321+
)}
322+
323+
<FlexBox justifyEnd gap={1} mt={2}>
324+
<Button onClick={onClose}>Cancel</Button>
325+
<Button
326+
variant="contained"
327+
onClick={handleAdd}
328+
disabled={!selected.length}
329+
>
330+
Add repos
331+
</Button>
332+
</FlexBox>
333+
</FlexBox>
334+
);
335+
};

0 commit comments

Comments
 (0)