From ddb1de53a34bd5c76256c3fc44fbf442fc292369 Mon Sep 17 00:00:00 2001 From: Sujai Kumar Gupta Date: Fri, 27 Jun 2025 20:55:14 +0530 Subject: [PATCH 1/5] feat: Add batch import functionality and pagination to repository selection --- .../src/components/Teams/CreateTeams.tsx | 211 +++++++++++------- 1 file changed, 132 insertions(+), 79 deletions(-) diff --git a/web-server/src/components/Teams/CreateTeams.tsx b/web-server/src/components/Teams/CreateTeams.tsx index 726c55c27..20ee34f0f 100644 --- a/web-server/src/components/Teams/CreateTeams.tsx +++ b/web-server/src/components/Teams/CreateTeams.tsx @@ -18,10 +18,13 @@ import { useTheme, InputLabel, FormControl, - OutlinedInput + OutlinedInput, + IconButton, + Box, + Pagination } from '@mui/material'; import { useSnackbar } from 'notistack'; -import { FC, useCallback } from 'react'; +import { FC, useCallback, useMemo, useState } from 'react'; import { useTeamCRUD, @@ -29,11 +32,14 @@ import { } from '@/components/Teams/useTeamsConfig'; import { DeploymentWorkflowSelector } from '@/components/WorkflowSelector'; import { Integration } from '@/constants/integrations'; +import { useModal } from '@/contexts/ModalContext'; import { useBoolState, useEasyState } from '@/hooks/useEasyState'; import GitlabIcon from '@/mocks/icons/gitlab.svg'; import { BaseRepo, DeploymentSources } from '@/types/resources'; import { trimWithEllipsis } from '@/utils/stringFormatting'; +import { BatchImportModal } from './BatchImportModal'; + import AnimatedInputWrapper from '../AnimatedInputWrapper/AnimatedInputWrapper'; import { FlexBox } from '../FlexBox'; import { Line } from '../Text'; @@ -164,6 +170,7 @@ const TeamRepos: FC = () => { loadingRepos, handleReposSearch } = useTeamCRUD(); + const { addModal, closeModal } = useModal(); const searchQuery = useEasyState(''); const searchFocus = useBoolState(false); @@ -192,6 +199,27 @@ const TeamRepos: FC = () => { ); }; + const openBatchImportModal = useCallback(() => { + const modal = addModal({ + title: 'Batch Import Repositories', + body: ( + { + const merged = [...selectedRepos, ...batch]; + const uniqueById = Array.from( + new Map(merged.map((repo) => [repo.id, repo])).values() + ); + handleRepoSelectionChange({} as any, uniqueById); + closeModal(modal.key); + }} + onClose={() => closeModal(modal.key)} + /> + ), + showCloseIcon: true + }); + }, [addModal, closeModal, selectedRepos, handleRepoSelectionChange]); + return ( @@ -200,7 +228,7 @@ const TeamRepos: FC = () => { Select repositories for this team using name or link - + { )} renderTags={() => null} /> - + + or + + + ); @@ -367,88 +405,103 @@ const ActionTray: FC = ({ const DisplayRepos: FC = () => { const { selectedRepos, showWorkflowChangeWarning, unselectRepo } = useTeamCRUD(); - const theme = useTheme(); - if (!selectedRepos.length) return; + + const ROWS_PER_PAGE = 8; + const [page, setPage] = useState(1); + const total = selectedRepos.length; + const pageCount = Math.ceil(total / ROWS_PER_PAGE); + const paged = useMemo( + () => selectedRepos.slice((page - 1) * ROWS_PER_PAGE, page * ROWS_PER_PAGE), + [selectedRepos, page] + ); + + if (!total) return null; + return ( - - - - - Repo - Deployed Via - Action - - - - {selectedRepos.map((repo) => { - const shortenedName = trimWithEllipsis(repo.name, 40); - return ( - - - - {repo.provider === Integration.GITHUB ? ( - - ) : ( - - )} - {shortenedName} - - - - - {' '} - {repo.deployment_type === DeploymentSources.WORKFLOW && ( - - )} - - - - { - unselectRepo(repo.id); - }} - > - + + +
+ + + Repo + Deployed Via + Action + + + + {paged.map((repo) => { + const name = trimWithEllipsis(repo.name, 40); + return ( + + + + {repo.provider === Integration.GITHUB ? ( + + ) : ( + + )} + {name} + + + + + {' '} + {repo.deployment_type === DeploymentSources.WORKFLOW && ( + + )} + + + + unselectRepo(repo.id)} + size="small" + > + + + + + ); + })} + {showWorkflowChangeWarning && ( + + + + + + Workflow changes will apply to all teams using these + repos. + - ); - })} - - {showWorkflowChangeWarning && ( - - - - - - Workflow selection for any repositories will apply to all - teams where they are assigned. - - - - - )} -
-
+ )} + + + + + {pageCount > 1 && ( + + setPage(p)} + size="small" + /> + + )} + ); }; - const options = [ { label: 'PR Merge', From 5120fe9d26d032ee5ccd95096709fcced5f84181 Mon Sep 17 00:00:00 2001 From: Sujai Kumar Gupta Date: Fri, 27 Jun 2025 20:55:37 +0530 Subject: [PATCH 2/5] feat: Implement Batch Import Modal for repository selection --- .../src/components/Teams/BatchImportModal.tsx | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 web-server/src/components/Teams/BatchImportModal.tsx diff --git a/web-server/src/components/Teams/BatchImportModal.tsx b/web-server/src/components/Teams/BatchImportModal.tsx new file mode 100644 index 000000000..775879e68 --- /dev/null +++ b/web-server/src/components/Teams/BatchImportModal.tsx @@ -0,0 +1,325 @@ +import { MoreVert as MoreVertIcon } from '@mui/icons-material'; +import { + Button, + FormControl, + InputLabel, + Select, + MenuItem, + TextField, + Box, + Checkbox, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + TableContainer, + Paper, + Alert, + Pagination, + Chip, + IconButton, + Menu, + MenuList, + ListItemText +} from '@mui/material'; +import axios from 'axios'; +import { useSnackbar } from 'notistack'; +import { FC, useEffect, useState, useRef } from 'react'; + +import { Integration } from '@/constants/integrations'; +import { useAuth } from '@/hooks/useAuth'; +import { BaseRepo } from '@/types/resources'; + +import { FlexBox } from '../FlexBox'; + +export interface BatchImportModalProps { + onClose: () => void; + onAdd: (repos: BaseRepo[]) => void; + existing: BaseRepo[]; +} + +const PAGE_SIZE = 50; + +interface PageData { + repos: BaseRepo[]; + endCursor: string | null; + hasNextPage: boolean; +} + +export const BatchImportModal: FC = ({ + onClose, + onAdd, + existing +}) => { + const { orgId } = useAuth(); + const { enqueueSnackbar } = useSnackbar(); + + const [provider, setProvider] = useState(Integration.GITHUB); + const [orgName, setOrgName] = useState(''); + const [pages, setPages] = useState>({}); + const [currentPage, setCurrentPage] = useState(1); + const [filtered, setFiltered] = useState([]); + const [selected, setSelected] = useState([...existing]); + const [loadingPage, setLoadingPage] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + + const didMountRef = useRef(false); + + useEffect(() => { + if (!didMountRef.current) { + setSelected([...existing]); + didMountRef.current = true; + } + }, [existing]); + + const fetchPage = async (pageNum: number) => { + if (pages[pageNum]) { + setFiltered(pages[pageNum].repos); + setCurrentPage(pageNum); + return; + } + + const prev = pages[pageNum - 1]; + const params: any = { provider, org: orgName, first: PAGE_SIZE }; + if (prev?.endCursor) { + params.after = prev.endCursor; + } + + setLoadingPage(true); + try { + const resp = await axios.get(`/api/internal/${orgId}/git_org_repos`, { + params + }); + const { repos, pageInfo } = resp.data; + const pageData: PageData = { + repos, + endCursor: pageInfo.endCursor, + hasNextPage: pageInfo.hasNextPage + }; + setPages((p) => ({ ...p, [pageNum]: pageData })); + setFiltered(repos); + setCurrentPage(pageNum); + } catch (e) { + console.error(e); + enqueueSnackbar('Failed to load page', { variant: 'error' }); + } finally { + setLoadingPage(false); + } + }; + + const fetchAll = async (): Promise => { + setLoadingPage(true); + try { + const resp = await axios.get(`/api/internal/${orgId}/git_org_repos`, { + params: { provider, org: orgName, select_all: true } + }); + return resp.data.repos as BaseRepo[]; + } catch { + enqueueSnackbar('Failed to load all repos', { variant: 'error' }); + return []; + } finally { + setLoadingPage(false); + } + }; + + const handleFilter = (q: string) => { + const lower = q.toLowerCase(); + const current = pages[currentPage]?.repos || []; + setFiltered( + current.filter((r) => + `${r.parent}/${r.name}`.toLowerCase().includes(lower) + ) + ); + }; + + const toggleOne = (repo: BaseRepo) => { + setSelected((sel) => + sel.some((r) => r.id === repo.id) + ? sel.filter((r) => r.id !== repo.id) + : [...sel, repo] + ); + }; + + const visible = filtered; + const openMenu = (e: React.MouseEvent) => + setAnchorEl(e.currentTarget); + const closeMenu = () => setAnchorEl(null); + + const selectVisible = () => { + setSelected((sel) => [ + ...sel, + ...visible.filter((r) => !sel.some((x) => x.id === r.id)) + ]); + closeMenu(); + }; + const deselectVisible = () => { + const visIds = new Set(visible.map((r) => r.id)); + setSelected((sel) => sel.filter((r) => !visIds.has(r.id))); + closeMenu(); + }; + const selectEverything = async () => { + const all = await fetchAll(); + setSelected((sel) => { + const map = new Map(); + [...sel, ...all].forEach((r) => map.set(r.id as number, r)); + return Array.from(map.values()); + }); + closeMenu(); + }; + + const handleAdd = () => { + onAdd(selected); + onClose(); + }; + + return ( + + + Batch Import Repositories + + + + + Provider + + + + setOrgName(e.target.value)} + fullWidth + /> + + + + + {selected.length > 0 && ( + + {selected.map((r) => ( + toggleOne(r)} + /> + ))} + + )} + + {visible.length > 0 && ( + <> + + handleFilter(e.target.value)} + fullWidth + /> + + + + + + + + + + + + + + + + + + + {selected.length > 10 && ( + + You’ve selected {selected.length} repositories. Initial sync may + take longer for large batches. + + )} + + + + + + + Repository + + + + {visible.map((repo) => ( + + + r.id === repo.id)} + onChange={() => toggleOne(repo)} + /> + + + {repo.parent}/{repo.name} + + + ))} + +
+
+ + + fetchPage(p)} + size="small" + disabled={loadingPage} + /> + + + )} + + + + + +
+ ); +}; From da248786b96a1ce0caac417cad7fcd26122a9c32 Mon Sep 17 00:00:00 2001 From: Sujai Kumar Gupta <120925871+thesujai@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:26:44 +0530 Subject: [PATCH 3/5] add request cancellation to git_orgs_repo Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/components/Teams/BatchImportModal.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/web-server/src/components/Teams/BatchImportModal.tsx b/web-server/src/components/Teams/BatchImportModal.tsx index 775879e68..652c4cf31 100644 --- a/web-server/src/components/Teams/BatchImportModal.tsx +++ b/web-server/src/components/Teams/BatchImportModal.tsx @@ -74,6 +74,9 @@ export const BatchImportModal: FC = ({ }, [existing]); const fetchPage = async (pageNum: number) => { + // allow cancelling the request to avoid race conditions / setState on unmounted + const controller = new AbortController(); + if (pages[pageNum]) { setFiltered(pages[pageNum].repos); setCurrentPage(pageNum); @@ -88,24 +91,34 @@ export const BatchImportModal: FC = ({ setLoadingPage(true); try { - const resp = await axios.get(`/api/internal/${orgId}/git_org_repos`, { - params - }); + const resp = await axios.get( + `/api/internal/${orgId}/git_org_repos`, + { + params, + signal: controller.signal, + } + ); const { repos, pageInfo } = resp.data; const pageData: PageData = { repos, endCursor: pageInfo.endCursor, - hasNextPage: pageInfo.hasNextPage + hasNextPage: pageInfo.hasNextPage, }; setPages((p) => ({ ...p, [pageNum]: pageData })); setFiltered(repos); setCurrentPage(pageNum); - } catch (e) { - console.error(e); - enqueueSnackbar('Failed to load page', { variant: 'error' }); + } catch (e: any) { + // ignore aborts, but report other errors + if (e.name !== 'AbortError') { + console.error(e); + enqueueSnackbar('Failed to load page', { variant: 'error' }); + } } finally { setLoadingPage(false); } + + // expose a cleanup to abort this request if needed + return () => controller.abort(); }; const fetchAll = async (): Promise => { From 58e3cc92427c7933087da78e7713c87e5f03822e Mon Sep 17 00:00:00 2001 From: Sujai Kumar Gupta <120925871+thesujai@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:27:16 +0530 Subject: [PATCH 4/5] add accessibility improvements to pagination Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- web-server/src/components/Teams/CreateTeams.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web-server/src/components/Teams/CreateTeams.tsx b/web-server/src/components/Teams/CreateTeams.tsx index 20ee34f0f..7f57d8a8c 100644 --- a/web-server/src/components/Teams/CreateTeams.tsx +++ b/web-server/src/components/Teams/CreateTeams.tsx @@ -496,6 +496,9 @@ const DisplayRepos: FC = () => { count={pageCount} onChange={(_, p) => setPage(p)} size="small" + aria-label="Repository list pagination" + showFirstButton + showLastButton /> )} From 04036955de6d5efd460c6188b10e2ffb8155fe1b Mon Sep 17 00:00:00 2001 From: Sujai Kumar Gupta <120925871+thesujai@users.noreply.github.com> Date: Fri, 27 Jun 2025 21:27:16 +0530 Subject: [PATCH 5/5] add accessibility improvements to pagination Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../src/components/Teams/BatchImportModal.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/web-server/src/components/Teams/BatchImportModal.tsx b/web-server/src/components/Teams/BatchImportModal.tsx index 652c4cf31..be6f41ba9 100644 --- a/web-server/src/components/Teams/BatchImportModal.tsx +++ b/web-server/src/components/Teams/BatchImportModal.tsx @@ -91,18 +91,15 @@ export const BatchImportModal: FC = ({ setLoadingPage(true); try { - const resp = await axios.get( - `/api/internal/${orgId}/git_org_repos`, - { - params, - signal: controller.signal, - } - ); + const resp = await axios.get(`/api/internal/${orgId}/git_org_repos`, { + params, + signal: controller.signal + }); const { repos, pageInfo } = resp.data; const pageData: PageData = { repos, endCursor: pageInfo.endCursor, - hasNextPage: pageInfo.hasNextPage, + hasNextPage: pageInfo.hasNextPage }; setPages((p) => ({ ...p, [pageNum]: pageData })); setFiltered(repos);