import React, { ReactElement, useEffect, useMemo, useState } from 'react'; import { Bullseye, Button, EmptyState, EmptyStateActions, EmptyStateBody, EmptyStateFooter, EmptyStateHeader, EmptyStateIcon, EmptyStateVariant, Icon, InputGroup, InputGroupItem, InputGroupText, Pagination, PaginationVariant, Popover, Spinner, Stack, Text, TextContent, TextInput, ToggleGroup, ToggleGroupItem, Toolbar, ToolbarContent, ToolbarItem, } from '@patternfly/react-core'; import { Modal } from '@patternfly/react-core'; import { ExternalLinkAltIcon, HelpIcon, OptimizeIcon, SearchIcon, TimesIcon, } from '@patternfly/react-icons'; import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table'; import { useDispatch } from 'react-redux'; import CustomHelperText from './components/CustomHelperText'; import PackageInfoNotAvailablePopover from './components/PackageInfoNotAvailablePopover'; import { CONTENT_BETA, CONTENT_STABLE, EPEL_8_REPO_DEFINITION, EPEL_9_REPO_DEFINITION, RH_ICON_SIZE, } from '../../../../constants'; import { ApiRepositoryResponseRead, useCreateRepositoryMutation, useListRepositoriesQuery, useSearchRpmMutation, useSearchPackageGroupMutation, } from '../../../../store/contentSourcesApi'; import { useAppSelector } from '../../../../store/hooks'; import { Package, useGetArchitecturesQuery, } from '../../../../store/imageBuilderApi'; import { selectArchitecture, selectPackages, selectGroups, selectCustomRepositories, selectDistribution, addPackage, removePackage, addGroup, removeGroup, addRecommendedRepository, removeRecommendedRepository, selectRecommendedRepositories, } from '../../../../store/wizardSlice'; import useDebounce from '../../../../Utilities/useDebounce'; import { useGetEnvironment } from '../../../../Utilities/useGetEnvironment'; type PackageRepository = 'distro' | 'custom' | 'recommended' | ''; export type IBPackageWithRepositoryInfo = { name: Package['name']; summary: Package['summary']; repository: PackageRepository; }; export type GroupWithRepositoryInfo = { name: string; description: string; repository: PackageRepository; package_list: string[]; }; export enum RepoToggle { INCLUDED = 'toggle-included-repos', OTHER = 'toggle-other-repos', } export const RedHatRepository = () => { return ( <> {' '} Red Hat logo{' '} Red Hat repository ); }; const Packages = () => { const dispatch = useDispatch(); const arch = useAppSelector(selectArchitecture); const distribution = useAppSelector(selectDistribution); const customRepositories = useAppSelector(selectCustomRepositories); const recommendedRepositories = useAppSelector(selectRecommendedRepositories); const packages = useAppSelector(selectPackages); const groups = useAppSelector(selectGroups); const { data: distroRepositories, isSuccess: isSuccessDistroRepositories } = useGetArchitecturesQuery({ distribution: distribution, }); // select the correct version of EPEL repository // the urls are copied over from the content service const epelRepoUrlByDistribution = distribution.startsWith('rhel-8') ? EPEL_8_REPO_DEFINITION.url : EPEL_9_REPO_DEFINITION.url; const { data: epelRepo, isSuccess: isSuccessEpelRepo } = useListRepositoriesQuery({ url: epelRepoUrlByDistribution, }); const [isRepoModalOpen, setIsRepoModalOpen] = useState(false); const [isSelectingPackage, setIsSelectingPackage] = useState< IBPackageWithRepositoryInfo | undefined >(); const [isSelectingGroup, setIsSelectingGroup] = useState< GroupWithRepositoryInfo | undefined >(); const [perPage, setPerPage] = useState(10); const [page, setPage] = useState(1); const [toggleSelected, setToggleSelected] = useState('toggle-available'); const [toggleSourceRepos, setToggleSourceRepos] = useState( RepoToggle.INCLUDED ); const [searchTerm, setSearchTerm] = useState(''); const [ searchCustomRpms, { data: dataCustomPackages, isSuccess: isSuccessCustomPackages, isLoading: isLoadingCustomPackages, }, ] = useSearchRpmMutation(); const debouncedSearchTerm = useDebounce(searchTerm.trim()); const debouncedSearchTermLengthOf1 = debouncedSearchTerm.length === 1; const debouncedSearchTermIsGroup = debouncedSearchTerm.startsWith('@'); // While it's searching for packages or groups, only show either packages or groups, without mixing the two. const showPackages = (debouncedSearchTerm && !debouncedSearchTermIsGroup) || toggleSelected === 'toggle-selected'; const showGroups = (debouncedSearchTerm && debouncedSearchTermIsGroup) || toggleSelected === 'toggle-selected'; const [ searchRecommendedRpms, { data: dataRecommendedPackages, isSuccess: isSuccessRecommendedPackages, isLoading: isLoadingRecommendedPackages, }, ] = useSearchRpmMutation(); const [ searchDistroRpms, { data: dataDistroPackages, isSuccess: isSuccessDistroPackages, isLoading: isLoadingDistroPackages, }, ] = useSearchRpmMutation(); const [ searchDistroGroups, { data: dataDistroGroups, isSuccess: isSuccessDistroGroups, isLoading: isLoadingDistroGroups, }, ] = useSearchPackageGroupMutation(); const [ searchCustomGroups, { data: dataCustomGroups, isSuccess: isSuccessCustomGroups, isLoading: isLoadingCustomGroups, }, ] = useSearchPackageGroupMutation(); const [ searchRecommendedGroups, { data: dataRecommendedGroups, isSuccess: isSuccessRecommendedGroups, isLoading: isLoadingRecommendedGroups, }, ] = useSearchPackageGroupMutation(); const [createRepository, { isLoading: createLoading }] = useCreateRepositoryMutation(); const sortfn = (a: string, b: string) => { const aPkg = a.toLowerCase(); const bPkg = b.toLowerCase(); // check exact match first if (aPkg === debouncedSearchTerm) { return -1; } if (bPkg === debouncedSearchTerm) { return 1; } // check for packages that start with the search term if ( aPkg.startsWith(debouncedSearchTerm) && !bPkg.startsWith(debouncedSearchTerm) ) { return -1; } if ( bPkg.startsWith(debouncedSearchTerm) && !aPkg.startsWith(debouncedSearchTerm) ) { return 1; } // if both (or neither) start with the search term // sort alphabetically if (aPkg < bPkg) { return -1; } if (bPkg < aPkg) { return 1; } return 0; }; useEffect(() => { if (debouncedSearchTermIsGroup) { return; } if (debouncedSearchTerm.length > 1 && isSuccessDistroRepositories) { searchDistroRpms({ apiContentUnitSearchRequest: { search: debouncedSearchTerm, urls: distroRepositories .filter((archItem) => { return archItem.arch === arch; })[0] .repositories.flatMap((repo) => { if (!repo.baseurl) { throw new Error(`Repository ${repo} missing baseurl`); } return repo.baseurl; }), }, }); } if (debouncedSearchTerm.length > 2) { if (toggleSourceRepos === RepoToggle.INCLUDED) { searchCustomRpms({ apiContentUnitSearchRequest: { search: debouncedSearchTerm, urls: customRepositories.flatMap((repo) => { if (!repo.baseurl) { throw new Error( `Repository (id: ${repo.id}, name: ${repo?.name}) is missing baseurl` ); } return repo.baseurl; }), }, }); } else { searchRecommendedRpms({ apiContentUnitSearchRequest: { search: debouncedSearchTerm, urls: [epelRepoUrlByDistribution], }, }); } } }, [ customRepositories, searchCustomRpms, searchDistroRpms, debouncedSearchTerm, toggleSourceRepos, searchRecommendedRpms, epelRepoUrlByDistribution, isSuccessDistroRepositories, searchDistroRpms, distroRepositories, arch, ]); useEffect(() => { if (!debouncedSearchTermIsGroup) { return; } if (isSuccessDistroRepositories) { searchDistroGroups({ apiContentUnitSearchRequest: { search: debouncedSearchTerm.substr(1), urls: distroRepositories .filter((archItem) => { return archItem.arch === arch; })[0] .repositories.flatMap((repo) => { if (!repo.baseurl) { throw new Error(`Repository ${repo} missing baseurl`); } return repo.baseurl; }), }, }); } if ( toggleSourceRepos === RepoToggle.INCLUDED && customRepositories.length > 0 ) { searchCustomGroups({ apiContentUnitSearchRequest: { search: debouncedSearchTerm.substr(1), urls: customRepositories.flatMap((repo) => { if (!repo.baseurl) { throw new Error( `Repository (id: ${repo.id}, name: ${repo?.name}) is missing baseurl` ); } return repo.baseurl; }), }, }); } else if (toggleSourceRepos === RepoToggle.OTHER && isSuccessEpelRepo) { searchRecommendedGroups({ apiContentUnitSearchRequest: { search: debouncedSearchTerm.substr(1), urls: [epelRepoUrlByDistribution], }, }); } }, [ customRepositories, searchDistroGroups, searchCustomGroups, searchRecommendedGroups, debouncedSearchTerm, toggleSourceRepos, epelRepoUrlByDistribution, ]); const EmptySearch = () => { return ( } /> {toggleSelected === 'toggle-available' ? ( Search above to add additional
packages to your image.
) : ( No packages selected.
Search above to see available packages.
)}
); }; const Searching = () => { return ( } /> {toggleSourceRepos === RepoToggle.OTHER ? 'Searching for recommendations' : 'Searching'} ); }; const TooManyResults = () => { return ( } titleText="Too many results to display" headingLevel="h4" /> Please make the search more specific and try again. ); }; const TooShort = () => { return ( } titleText="The search value is too short" headingLevel="h4" /> Please make the search more specific and try again. ); }; const TooManyResultsWithExactMatch = () => { return ( To see more results, please make the search more specific and try again. ); }; const TryLookingUnderIncluded = () => { return ( Try looking under " ". ); }; const NoResultsFound = () => { const { isBeta } = useGetEnvironment(); if (toggleSourceRepos === RepoToggle.INCLUDED) { return ( } /> Adjust your search and try again, or search in other repositories (your repositories and popular repositories). ); } else { return ( } /> No packages found in known repositories. If you know of a repository containing this packages, add it to{' '} {' '} and try searching for it again. ); } }; const RepositoryModal = () => { const { isBeta } = useGetEnvironment(); return ( Add listed repositories , , ]} ouiaId="Custom-repos-warning-modal" > You have selected packages that belong to custom repositories. By continuing, you are acknowledging and consenting to adding the following custom repositories to your image.

The repositories will also get enabled in{' '} {' '} if they were not enabled yet:
{isSelectingPackage ? : } {isSelectingPackage ? ( ) : ( )}
PackagesPackage groupsRepositories
{isSelectingPackage?.name}{isSelectingGroup?.name} EPEL {distribution === 'rhel-8' ? '8' : '9'} Everything x86_64

To move forward, either add the repos to your image, or go back to review your package selections.
); }; const transformedPackages = useMemo(() => { let transformedDistroData: IBPackageWithRepositoryInfo[] = []; let transformedCustomData: IBPackageWithRepositoryInfo[] = []; let transformedRecommendedData: IBPackageWithRepositoryInfo[] = []; if (isSuccessDistroPackages) { transformedDistroData = dataDistroPackages!.map((values) => ({ name: values.package_name!, summary: values.summary!, repository: 'distro', })); } if (isSuccessCustomPackages) { transformedCustomData = dataCustomPackages!.map((values) => ({ name: values.package_name!, summary: values.summary!, repository: 'custom', })); } let combinedPackageData = transformedDistroData.concat( transformedCustomData ); if ( debouncedSearchTerm !== '' && combinedPackageData.length === 0 && isSuccessRecommendedPackages && toggleSourceRepos === RepoToggle.OTHER ) { transformedRecommendedData = dataRecommendedPackages!.map((values) => ({ name: values.package_name!, summary: values.summary!, repository: 'recommended', })); combinedPackageData = combinedPackageData.concat( transformedRecommendedData ); } if (toggleSelected === 'toggle-available') { if (toggleSourceRepos === RepoToggle.INCLUDED) { return combinedPackageData.filter( (pkg) => pkg.repository !== 'recommended' ); } else { return combinedPackageData.filter( (pkg) => pkg.repository === 'recommended' ); } } else { const selectedPackages = [...packages]; if (toggleSourceRepos === RepoToggle.INCLUDED) { return selectedPackages; } else { return []; } } }, [ dataCustomPackages, dataDistroPackages, dataRecommendedPackages, debouncedSearchTerm, isSuccessCustomPackages, isSuccessDistroPackages, isSuccessRecommendedPackages, packages, toggleSelected, toggleSourceRepos, ]).sort((a, b) => sortfn(a.name, b.name)); const transformedGroups = useMemo(() => { let combinedGroupData: GroupWithRepositoryInfo[] = []; if (isSuccessDistroGroups) { combinedGroupData = combinedGroupData.concat( dataDistroGroups!.map((values) => ({ name: values.id!, description: values.description!, repository: 'distro', package_list: values.package_list!, })) ); } if (isSuccessCustomGroups) { combinedGroupData = combinedGroupData.concat( dataCustomGroups!.map((values) => ({ name: values.id!, description: values.description!, repository: 'custom', package_list: values.package_list!, })) ); } if (isSuccessRecommendedGroups) { combinedGroupData = combinedGroupData.concat( dataRecommendedGroups!.map((values) => ({ name: values.id!, description: values.description!, repository: 'recommended', package_list: values.package_list!, })) ); } if (toggleSelected === 'toggle-available') { if (toggleSourceRepos === RepoToggle.INCLUDED) { return combinedGroupData.filter( (pkg) => pkg.repository !== 'recommended' ); } else { return combinedGroupData.filter( (pkg) => pkg.repository === 'recommended' ); } } else { const selectedGroups = [...groups]; if (toggleSourceRepos === RepoToggle.INCLUDED) { return selectedGroups; } else { return []; } } return combinedGroupData; }, [ dataDistroGroups, dataCustomGroups, dataRecommendedGroups, debouncedSearchTerm, isSuccessDistroGroups, isSuccessCustomGroups, isSuccessRecommendedGroups, groups, toggleSelected, toggleSourceRepos, ]).sort((a, b) => sortfn(a.name, b.name)); const handleSearch = async ( event: React.FormEvent, selection: string ) => { setSearchTerm(selection); }; const handleClear = async () => { setSearchTerm(''); }; const handleSelect = ( pkg: IBPackageWithRepositoryInfo, _: number, isSelecting: boolean ) => { if (isSelecting) { if ( isSuccessEpelRepo && epelRepo.data && pkg.repository === 'recommended' && !recommendedRepositories.some((repo) => repo.name?.startsWith('EPEL')) ) { setIsRepoModalOpen(true); setIsSelectingPackage(pkg); } else { dispatch(addPackage(pkg)); } } else { dispatch(removePackage(pkg.name)); if ( isSuccessEpelRepo && epelRepo.data && packages.filter((pkg) => pkg.repository === 'recommended').length === 1 && groups.filter((grp) => grp.repository === 'recommended').length === 0 ) { dispatch(removeRecommendedRepository(epelRepo.data[0])); } } }; const handleGroupSelect = ( grp: GroupWithRepositoryInfo, _: number, isSelecting: boolean ) => { if (isSelecting) { if ( isSuccessEpelRepo && epelRepo.data && grp.repository === 'recommended' && !recommendedRepositories.some((repo) => repo.name?.startsWith('EPEL')) ) { setIsRepoModalOpen(true); setIsSelectingGroup(grp); } else { dispatch(addGroup(grp)); } } else { dispatch(removeGroup(grp.name)); if ( isSuccessEpelRepo && epelRepo.data && groups.filter((grp) => grp.repository === 'recommended').length === 1 && packages.filter((pkg) => pkg.repository === 'recommended').length === 0 ) { dispatch(removeRecommendedRepository(epelRepo.data[0])); } } }; const handleFilterToggleClick = (event: React.MouseEvent) => { const id = event.currentTarget.id; setPage(1); setToggleSelected(id); }; const handleRepoToggleClick = (type: RepoToggle) => { if (toggleSourceRepos !== type) { setPage(1); setToggleSourceRepos(type); } }; const handleSetPage = (_: React.MouseEvent, newPage: number) => { setPage(newPage); }; const handlePerPageSelect = ( _: React.MouseEvent, newPerPage: number, newPage: number ) => { setPerPage(newPerPage); setPage(newPage); }; const computeStart = () => perPage * (page - 1); const computeEnd = () => perPage * page; const handleExactMatch = () => { const exactMatch = transformedPackages.find( (pkg) => pkg.name === debouncedSearchTerm ); if (exactMatch) { return ( <> p.name === exactMatch.name), rowIndex: 0, onSelect: (event, isSelecting) => handleSelect(exactMatch, 0, isSelecting), }} /> {exactMatch.name} {exactMatch.summary} {exactMatch.repository === 'distro' ? ( <> Supported ) : ( <> Third party repository Not supported )} ); } else { return ; } }; const handleCloseModalToggle = () => { setIsRepoModalOpen(!isRepoModalOpen); setIsSelectingPackage(undefined); }; const handleConfirmModalToggle = async () => { if (!epelRepo || !epelRepo.data) { throw new Error( `There was an error while adding the recommended repository.` ); } if (epelRepo.data.length === 0) { const result = await createRepository({ apiRepositoryRequest: distribution.startsWith('rhel-8') ? EPEL_8_REPO_DEFINITION : EPEL_9_REPO_DEFINITION, }); dispatch( addRecommendedRepository( (result as { data: ApiRepositoryResponseRead }).data ) ); } else { dispatch(addRecommendedRepository(epelRepo.data[0])); } if (isSelectingPackage) { dispatch(addPackage(isSelectingPackage!)); } if (isSelectingGroup) { dispatch(addGroup(isSelectingGroup!)); } setIsRepoModalOpen(!isRepoModalOpen); }; const composePkgTable = () => { let rows: ReactElement[] = []; if (showGroups) { rows = rows.concat( transformedGroups .slice(computeStart(), computeEnd()) .map((grp, rowIndex) => ( g.name === grp.name), rowIndex: rowIndex, onSelect: (event, isSelecting) => handleGroupSelect(grp, rowIndex, isSelecting), }} /> @{grp.name} 0 ? { height: '40em', overflow: 'scroll' } : {} } > {grp.package_list.length > 0 ? ( {grp.package_list.map((pkg) => ( ))}
{pkg}
) : ( This group has no packages )} } >
{grp.description ? ( grp.description ) : ( Not available )} {grp.repository === 'distro' ? ( <> Red Hat logo{' '} Red Hat repository Supported ) : grp.repository === 'custom' ? ( <> Third party repository Not supported ) : grp.repository === 'recommended' ? ( <> {' '} EPEL {distribution.startsWith('rhel-8') ? '8' : '9'}{' '} Everything x86_64 Not supported ) : ( <> Not available Not available )} )) ); } if (showPackages) { rows = rows.concat( transformedPackages .slice(computeStart(), computeEnd()) .map((pkg, rowIndex) => ( p.name === pkg.name), rowIndex: rowIndex, onSelect: (event, isSelecting) => handleSelect(pkg, rowIndex, isSelecting), }} /> {pkg.name} {pkg.summary ? ( pkg.summary ) : ( Not available )} {pkg.repository === 'distro' ? ( <> Red Hat logo{' '} Red Hat repository Supported ) : pkg.repository === 'custom' ? ( <> Third party repository Not supported ) : pkg.repository === 'recommended' ? ( <> {' '} EPEL {distribution.startsWith('rhel-8') ? '8' : '9'}{' '} Everything x86_64 Not supported ) : ( <> Not available Not available )} )) ); } return rows; }; const bodyContent = useMemo(() => { switch (true) { case debouncedSearchTermLengthOf1 && !debouncedSearchTermIsGroup && transformedPackages.length === 0 && transformedGroups.length === 0: return TooShort(); case (toggleSelected === 'toggle-selected' && packages.length === 0 && groups.length === 0) || (!debouncedSearchTerm && toggleSelected === 'toggle-available'): return ; case (debouncedSearchTerm && (isLoadingRecommendedPackages || isLoadingRecommendedGroups) && toggleSourceRepos === RepoToggle.OTHER) || (debouncedSearchTerm && (isLoadingDistroPackages || isLoadingCustomPackages || isLoadingDistroGroups || isLoadingCustomGroups) && toggleSourceRepos === RepoToggle.INCLUDED): return ; case debouncedSearchTerm && transformedPackages.length === 0 && transformedGroups.length === 0 && toggleSelected === 'toggle-available': return ; case debouncedSearchTerm && toggleSelected === 'toggle-selected' && toggleSourceRepos === RepoToggle.OTHER && packages.length > 0 && groups.length > 0: return ; case debouncedSearchTerm && transformedPackages.length >= 100: return handleExactMatch(); case (debouncedSearchTerm || toggleSelected === 'toggle-selected') && transformedPackages.length < 100 && transformedGroups.length < 100: return composePkgTable(); default: return <>; } // Would need significant rewrite to fix this // eslint-disable-next-line react-hooks/exhaustive-deps }, [ page, perPage, debouncedSearchTerm, debouncedSearchTermLengthOf1, debouncedSearchTermIsGroup, isLoadingCustomPackages, isLoadingDistroPackages, isLoadingRecommendedPackages, isSuccessRecommendedPackages, isLoadingDistroGroups, isLoadingCustomGroups, isLoadingRecommendedGroups, packages.length, groups.length, toggleSelected, toggleSourceRepos, transformedPackages, isSelectingPackage, recommendedRepositories, transformedPackages.length, transformedGroups.length, ]); return ( <> {searchTerm && ( } buttonId={RepoToggle.INCLUDED} isSelected={toggleSourceRepos === RepoToggle.INCLUDED} onChange={() => handleRepoToggleClick(RepoToggle.INCLUDED)} /> Other repos{' '} View packages from popular repositories and your other repositories not included in the image. } > } buttonId="toggle-other-repos" isSelected={toggleSourceRepos === RepoToggle.OTHER} onChange={() => handleRepoToggleClick(RepoToggle.OTHER)} /> {bodyContent}
Package name Description {toggleSelected === 'toggle-selected' && ( )} Package repository Support
); }; export default Packages;