diff --git a/web-server/src/components/Teams/BatchImportModal.tsx b/web-server/src/components/Teams/BatchImportModal.tsx new file mode 100644 index 000000000..be6f41ba9 --- /dev/null +++ b/web-server/src/components/Teams/BatchImportModal.tsx @@ -0,0 +1,335 @@ +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) => { + // allow cancelling the request to avoid race conditions / setState on unmounted + const controller = new AbortController(); + + 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, + signal: controller.signal + }); + 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: 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 => { + 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} + /> + + + )} + + + + + +
+ ); +}; diff --git a/web-server/src/components/Teams/CreateTeams.tsx b/web-server/src/components/Teams/CreateTeams.tsx index 726c55c27..7f57d8a8c 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,106 @@ 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" + aria-label="Repository list pagination" + showFirstButton + showLastButton + /> + + )} + ); }; - const options = [ { label: 'PR Merge',