import React, { ReactElement, useEffect, useMemo, useState } from 'react'; import { Bullseye, Button, DescriptionList, DescriptionListDescription, DescriptionListGroup, DescriptionListTerm, EmptyState, EmptyStateActions, EmptyStateBody, EmptyStateFooter, EmptyStateVariant, Icon, Pagination, PaginationVariant, Popover, SearchInput, Spinner, Stack, Tab, Tabs, TabTitleText, Content, ToggleGroup, ToggleGroupItem, Toolbar, ToolbarContent, ToolbarItem, } from '@patternfly/react-core'; import { Modal } from '@patternfly/react-core/deprecated'; import { CheckCircleIcon, ExclamationCircleIcon, ExclamationTriangleIcon, ExternalLinkAltIcon, HelpIcon, OptimizeIcon, SearchIcon, } from '@patternfly/react-icons'; import { ExpandableRowContent, 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 { IncludedReposPopover, OtherReposPopover, } from './components/RepoPopovers'; import { CONTENT_URL, ContentOrigin, EPEL_10_REPO_DEFINITION, } from '../../../../constants'; import { useGetArchitecturesQuery } from '../../../../store/backendApi'; import { ApiRepositoryResponseRead, useCreateRepositoryMutation, useListRepositoriesQuery, useSearchRpmMutation, useSearchPackageGroupMutation, ApiSearchRpmResponse, ApiPackageSourcesResponse, } from '../../../../store/contentSourcesApi'; import { useAppSelector } from '../../../../store/hooks'; import { Package } from '../../../../store/imageBuilderApi'; import { selectArchitecture, selectPackages, selectGroups, selectCustomRepositories, selectDistribution, addPackage, removePackage, addGroup, removeGroup, addRecommendedRepository, removeRecommendedRepository, selectRecommendedRepositories, addModule, removeModule, selectModules, } from '../../../../store/wizardSlice'; import { getEpelDefinitionForDistribution, getEpelUrlForDistribution, getEpelVersionForDistribution, } from '../../../../Utilities/epel'; import useDebounce from '../../../../Utilities/useDebounce'; export type PackageRepository = 'distro' | 'custom' | 'recommended' | ''; export type ItemWithSources = { name: Package['name']; summary: Package['summary']; repository: PackageRepository; sources?: ApiSearchRpmResponse['package_sources']; }; export type IBPackageWithRepositoryInfo = { name: Package['name']; summary: Package['summary']; repository: PackageRepository; type?: string; module_name?: string; stream?: string; end_date?: string; }; export type GroupWithRepositoryInfo = { name: string; description: string; repository: PackageRepository; package_list: string[]; }; export enum Repos { INCLUDED = 'included-repos', OTHER = 'other-repos', } 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 modules = useAppSelector(selectModules); const { data: distroRepositories, isSuccess: isSuccessDistroRepositories } = useGetArchitecturesQuery({ distribution: distribution, }); const epelRepoUrlByDistribution = getEpelUrlForDistribution(distribution) ?? EPEL_10_REPO_DEFINITION.url; const { data: epelRepo, isSuccess: isSuccessEpelRepo } = useListRepositoriesQuery({ url: epelRepoUrlByDistribution, origin: ContentOrigin.EXTERNAL, }); const [currentlyRemovedPackages, setCurrentlyRemovedPackages] = useState< IBPackageWithRepositoryInfo[] >([]); 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 [activeTabKey, setActiveTabKey] = useState(Repos.INCLUDED); const [searchTerm, setSearchTerm] = useState(''); const [activeStream, setActiveStream] = 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(); useEffect(() => { if (debouncedSearchTermIsGroup) { return; } if (debouncedSearchTerm.length > 1 && isSuccessDistroRepositories) { if (process.env.IS_ON_PREMISE) { searchDistroRpms({ apiContentUnitSearchRequest: { packages: [debouncedSearchTerm], architecture: arch, distribution, }, }); } else { 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; }), limit: 500, include_package_sources: true, }, }); } } if (debouncedSearchTerm.length > 2) { if (activeTabKey === Repos.INCLUDED && customRepositories.length > 0) { searchCustomRpms({ apiContentUnitSearchRequest: { search: debouncedSearchTerm, uuids: customRepositories.flatMap((repo) => { return repo.id; }), limit: 500, include_package_sources: true, }, }); } else { searchRecommendedRpms({ apiContentUnitSearchRequest: { search: debouncedSearchTerm, urls: [epelRepoUrlByDistribution], }, }); } } }, [ customRepositories, searchCustomRpms, searchDistroRpms, debouncedSearchTerm, activeTabKey, 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 (activeTabKey === Repos.INCLUDED && customRepositories.length > 0) { searchCustomGroups({ apiContentUnitSearchRequest: { search: debouncedSearchTerm.substr(1), uuids: customRepositories.flatMap((repo) => { return repo.id; }), }, }); } else if (activeTabKey === Repos.OTHER && isSuccessEpelRepo) { searchRecommendedGroups({ apiContentUnitSearchRequest: { search: debouncedSearchTerm.substr(1), urls: [epelRepoUrlByDistribution], }, }); } }, [ customRepositories, searchDistroGroups, searchCustomGroups, searchRecommendedGroups, debouncedSearchTerm, activeTabKey, 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 ( {activeTabKey === Repos.OTHER ? 'Searching for recommendations' : 'Searching'} ); }; const TooShort = () => { return ( Please make the search more specific and try again. ); }; const TryLookingUnderIncluded = () => { return ( Try looking under " ". ); }; const NoResultsFound = () => { if (activeTabKey === Repos.INCLUDED) { return ( Adjust your search and try again, or search in other repositories (your repositories and popular repositories). {!process.env.IS_ON_PREMISE && ( )} ); } 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 = () => { return ( Add listed repositories , , ]} > 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 {getEpelVersionForDistribution(distribution)} 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: ItemWithSources[] = []; let transformedCustomData: ItemWithSources[] = []; let transformedRecommendedData: ItemWithSources[] = []; if (isSuccessDistroPackages) { transformedDistroData = dataDistroPackages.map((values) => ({ name: values.package_name!, summary: values.summary!, repository: 'distro', sources: values.package_sources, })); } if (isSuccessCustomPackages) { transformedCustomData = dataCustomPackages.map((values) => ({ name: values.package_name!, summary: values.summary!, repository: 'custom', sources: values.package_sources, })); } let combinedPackageData = transformedDistroData.concat( transformedCustomData ); if ( debouncedSearchTerm !== '' && combinedPackageData.length === 0 && isSuccessRecommendedPackages && activeTabKey === Repos.OTHER ) { transformedRecommendedData = dataRecommendedPackages!.map((values) => ({ name: values.package_name!, summary: values.summary!, repository: 'recommended', sources: values.package_sources, })); combinedPackageData = combinedPackageData.concat( transformedRecommendedData ); } const unpackedData: IBPackageWithRepositoryInfo[] = combinedPackageData.flatMap((item) => { // Spread modules into separate rows by application stream if (item.sources) { return item.sources.map((source) => ({ name: item.name, summary: item.summary, repository: item.repository, type: source.type, module_name: source.name, stream: source.stream, end_date: source.end_date, })); } return [ { name: item.name, summary: item.summary, repository: item.repository, }, ]; }); // group by name, but sort by application stream in descending order unpackedData.sort((a, b) => { if (a.name === b.name) { return (b.stream ?? '').localeCompare(a.stream ?? ''); } else { return a.name.localeCompare(b.name); } }); if (toggleSelected === 'toggle-available') { if (activeTabKey === Repos.INCLUDED) { return unpackedData.filter((pkg) => pkg.repository !== 'recommended'); } return unpackedData.filter((pkg) => pkg.repository === 'recommended'); } else { const selectedPackages = [...packages]; if (currentlyRemovedPackages.length > 0) { selectedPackages.push(...currentlyRemovedPackages); } if (activeTabKey === Repos.INCLUDED) { return selectedPackages; } else { return []; } } }, [ currentlyRemovedPackages, dataCustomPackages, dataDistroPackages, dataRecommendedPackages, debouncedSearchTerm, isSuccessCustomPackages, isSuccessDistroPackages, isSuccessRecommendedPackages, packages, toggleSelected, activeTabKey, ]); 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 (activeTabKey === Repos.INCLUDED) { return combinedGroupData.filter( (pkg) => pkg.repository !== 'recommended' ); } else { return combinedGroupData.filter( (pkg) => pkg.repository === 'recommended' ); } } else { const selectedGroups = [...groups]; if (activeTabKey === Repos.INCLUDED) { return selectedGroups; } else { return []; } } return combinedGroupData; }, [ dataDistroGroups, dataCustomGroups, dataRecommendedGroups, debouncedSearchTerm, isSuccessDistroGroups, isSuccessCustomGroups, isSuccessRecommendedGroups, groups, toggleSelected, activeTabKey, ]); const handleSearch = async ( event: React.FormEvent, selection: string ) => { setSearchTerm(selection); setActiveTabKey(Repos.INCLUDED); setToggleSelected('toggle-available'); setActiveStream(''); setActiveSortIndex(0); setActiveSortDirection('asc'); }; const handleClear = async () => { setSearchTerm(''); setActiveTabKey(Repos.INCLUDED); setActiveStream(''); setActiveSortIndex(0); setActiveSortDirection('asc'); }; 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)); if (pkg.type === 'module') { setActiveStream(pkg.stream || ''); setActiveSortIndex(2); setPage(1); dispatch( addModule({ name: pkg.module_name || '', stream: pkg.stream || '', }) ); } setCurrentlyRemovedPackages((prev) => prev.filter((curr) => curr.name !== pkg.name) ); } } else { dispatch(removePackage(pkg.name)); if (pkg.type === 'module' && pkg.module_name) { dispatch(removeModule(pkg.module_name)); } setCurrentlyRemovedPackages((last) => [...last, pkg]); 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; setCurrentlyRemovedPackages([]); setPage(1); setToggleSelected(id); }; 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 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: getEpelDefinitionForDistribution(distribution) ?? EPEL_10_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 handleTabClick = (event: React.MouseEvent, tabIndex: Repos) => { if (tabIndex !== activeTabKey) { setCurrentlyRemovedPackages([]); setPage(1); setActiveTabKey(tabIndex); } }; const initialExpandedPkgs: IBPackageWithRepositoryInfo[] = []; const [expandedPkgs, setExpandedPkgs] = useState(initialExpandedPkgs); const setPkgExpanded = ( pkg: IBPackageWithRepositoryInfo, isExpanding: boolean ) => setExpandedPkgs((prevExpanded) => { const otherExpandedPkgs = prevExpanded.filter((p) => p.name !== pkg.name); return isExpanding ? [...otherExpandedPkgs, pkg] : otherExpandedPkgs; }); const isPkgExpanded = (pkg: IBPackageWithRepositoryInfo) => expandedPkgs.includes(pkg); const initialExpandedGroups: GroupWithRepositoryInfo['name'][] = []; const [expandedGroups, setExpandedGroups] = useState(initialExpandedGroups); const setGroupsExpanded = ( group: GroupWithRepositoryInfo['name'], isExpanding: boolean ) => setExpandedGroups((prevExpanded) => { const otherExpandedGroups = prevExpanded.filter((g) => g !== group); return isExpanding ? [...otherExpandedGroups, group] : otherExpandedGroups; }); const isGroupExpanded = (group: GroupWithRepositoryInfo['name']) => expandedGroups.includes(group); const [activeSortIndex, setActiveSortIndex] = useState(0); const [activeSortDirection, setActiveSortDirection] = useState< 'asc' | 'desc' >('asc'); const getSortableRowValues = ( pkg: IBPackageWithRepositoryInfo ): (string | number | ApiPackageSourcesResponse[] | undefined)[] => { return [pkg.name, pkg.summary, pkg.stream, pkg.end_date, pkg.repository]; }; let sortedPackages = transformedPackages; sortedPackages = transformedPackages.sort((a, b) => { const aValue = getSortableRowValues(a)[activeSortIndex]; const bValue = getSortableRowValues(b)[activeSortIndex]; if (typeof aValue === 'number') { // Numeric sort if (activeSortDirection === 'asc') { return (aValue as number) - (bValue as number); } return (bValue as number) - (aValue as number); } // String sort // if active stream is set, sort it to the top if (aValue === activeStream) { return -1; } if (bValue === activeStream) { return 1; } if (activeSortDirection === 'asc') { // handle packages with undefined stream if (!aValue) { return -1; } if (!bValue) { return 1; } return (aValue as string).localeCompare(bValue as string); } else { // handle packages with undefined stream if (!aValue) { return 1; } if (!bValue) { return -1; } return (bValue as string).localeCompare(aValue as string); } }); const getSortParams = (columnIndex: number) => ({ sortBy: { index: activeSortIndex, direction: activeSortDirection, }, onSort: ( _event: React.MouseEvent, index: number, direction: 'asc' | 'desc' ) => { setActiveSortIndex(index); setActiveSortDirection(direction); }, columnIndex, }); const isPackageSelected = (pkg: IBPackageWithRepositoryInfo) => { let isSelected = false; if (!pkg.type || pkg.type === 'package') { const isModuleWithSameName = modules.some( (module) => module.name === pkg.name ); isSelected = packages.some((p) => p.name === pkg.name) && !isModuleWithSameName; } if (pkg.type === 'module') { // the package is selected if it's added to the packages state // and its module stream matches one in enabled_modules isSelected = packages.some((p) => p.name === pkg.name) && modules.some( (m) => m.name === pkg.module_name && m.stream === pkg.stream ); } return isSelected; }; /** * Determines if the package's (or group's) select is disabled. * * Select should be disabled: * - if the item is a module * - and the module is added to enabled_modules * - but the stream doesn't match the stream in enabled_modules * * @param pkg Package * @returns Package (or group) is / is not selected */ const isSelectDisabled = (pkg: IBPackageWithRepositoryInfo) => { const isModuleDisabledByPackage = pkg.type === 'module' && packages.some( (p) => (!p.type || p.type === 'package') && p.name === pkg.module_name ); const isPackageDisabledByModule = (!pkg.type || pkg.type === 'package') && modules.some((module) => module.name === pkg.name); const isModuleStreamConflict = pkg.type === 'module' && modules.some((module) => module.name === pkg.module_name) && !modules.some((m) => m.stream === pkg.stream); return ( isModuleDisabledByPackage || isPackageDisabledByModule || isModuleStreamConflict ); }; const formatDate = (date: string | undefined) => { if (!date) { return <>N/A; } const retirementDate = new Date(date); const currentDate = new Date(); const msPerDay = 1000 * 60 * 60 * 24; const differenceInDays = Math.round( (retirementDate.getTime() - currentDate.getTime()) / msPerDay ); let icon; switch (true) { case differenceInDays < 0: icon = ( ); break; case differenceInDays <= 365: icon = ( ); break; case differenceInDays > 365: icon = ( ); break; } return ( <> {icon}{' '} {retirementDate.toLocaleString('en-US', { month: 'short' }) + ' ' + retirementDate.getFullYear()} ); }; const composePkgTable = () => { let rows: ReactElement[] = []; if (showGroups) { rows = rows.concat( transformedGroups .slice(computeStart(), computeEnd()) .map((grp, rowIndex) => ( setGroupsExpanded(grp.name, !isGroupExpanded(grp.name)), expandId: `${grp.name}-expandable`, }} /> 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 )} } >