debian-image-builder-frontend/src/Components/CreateImageWizard/steps/Packages/Packages.tsx
Dominik Vagner e05079330b Wizard: add EPEL 10 support
This adds support for EPEL 10 repository. Changes the way of getting
the correct EPEL version for RHEL distribution to be more future proof.
2025-06-30 08:43:32 +00:00

1570 lines
46 KiB
TypeScript

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<string>('');
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 (
<Tbody>
<Tr>
<Td colSpan={6}>
<Bullseye>
<EmptyState icon={SearchIcon} variant={EmptyStateVariant.sm}>
{toggleSelected === 'toggle-available' ? (
<EmptyStateBody>
Search above to add additional
<br />
packages to your image.
</EmptyStateBody>
) : (
<EmptyStateBody>
No packages selected.
<br />
Search above to see available packages.
</EmptyStateBody>
)}
</EmptyState>
</Bullseye>
</Td>
</Tr>
</Tbody>
);
};
const Searching = () => {
return (
<Tbody>
<Tr>
<Td colSpan={6}>
<Bullseye>
<EmptyState icon={Spinner} variant={EmptyStateVariant.sm}>
<EmptyStateBody>
{activeTabKey === Repos.OTHER
? 'Searching for recommendations'
: 'Searching'}
</EmptyStateBody>
</EmptyState>
</Bullseye>
</Td>
</Tr>
</Tbody>
);
};
const TooShort = () => {
return (
<Tbody>
<Tr>
<Td colSpan={6}>
<Bullseye>
<EmptyState
headingLevel="h4"
icon={SearchIcon}
titleText="The search value is too short"
variant={EmptyStateVariant.sm}
>
<EmptyStateBody>
Please make the search more specific and try again.
</EmptyStateBody>
</EmptyState>
</Bullseye>
</Td>
</Tr>
</Tbody>
);
};
const TryLookingUnderIncluded = () => {
return (
<Tbody>
<Tr>
<Td colSpan={6}>
<Bullseye>
<EmptyState
headingLevel="h4"
titleText="No selected packages in Other repos"
variant={EmptyStateVariant.sm}
>
<EmptyStateBody>
Try looking under &quot;
<Button
variant="link"
onClick={() => setActiveTabKey(Repos.INCLUDED)}
isInline
>
Included repos
</Button>
&quot;.
</EmptyStateBody>
</EmptyState>
</Bullseye>
</Td>
</Tr>
</Tbody>
);
};
const NoResultsFound = () => {
if (activeTabKey === Repos.INCLUDED) {
return (
<Tbody>
<Tr>
<Td colSpan={6}>
<Bullseye>
<EmptyState
headingLevel="h4"
titleText="No results found"
icon={SearchIcon}
variant={EmptyStateVariant.sm}
>
<EmptyStateBody>
Adjust your search and try again, or search in other
repositories (your repositories and popular repositories).
</EmptyStateBody>
<EmptyStateFooter>
<EmptyStateActions>
{!process.env.IS_ON_PREMISE && (
<Button
variant="primary"
onClick={() => setActiveTabKey(Repos.OTHER)}
>
Search other repositories
</Button>
)}
</EmptyStateActions>
<EmptyStateActions>
<Button
className="pf-v6-u-pt-md"
variant="link"
isInline
component="a"
target="_blank"
iconPosition="right"
icon={<ExternalLinkAltIcon />}
href={CONTENT_URL}
>
Manage your repositories and popular repositories
</Button>
</EmptyStateActions>
</EmptyStateFooter>
</EmptyState>
</Bullseye>
</Td>
</Tr>
</Tbody>
);
} else {
return (
<Tbody>
<Tr>
<Td colSpan={6}>
<Bullseye>
<EmptyState
headingLevel="h4"
titleText="No results found"
icon={SearchIcon}
variant={EmptyStateVariant.sm}
>
<EmptyStateBody>
No packages found in known repositories. If you know of a
repository containing this packages, add it to{' '}
<Button
variant="link"
isInline
component="a"
target="_blank"
href={CONTENT_URL}
>
your repositories
</Button>{' '}
and try searching for it again.
</EmptyStateBody>
</EmptyState>
</Bullseye>
</Td>
</Tr>
</Tbody>
);
}
};
const RepositoryModal = () => {
return (
<Modal
titleIconVariant="warning"
title="Custom repositories will be added to your image"
isOpen={isRepoModalOpen}
onClose={handleCloseModalToggle}
width="50%"
actions={[
<Button
key="add"
variant="primary"
isLoading={createLoading}
isDisabled={createLoading}
onClick={handleConfirmModalToggle}
>
Add listed repositories
</Button>,
<Button key="back" variant="link" onClick={handleCloseModalToggle}>
Back
</Button>,
]}
>
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.
<br />
<br />
The repositories will also get enabled in{' '}
<Button
component="a"
target="_blank"
variant="link"
iconPosition="right"
isInline
icon={<ExternalLinkAltIcon />}
href={CONTENT_URL}
>
content services
</Button>{' '}
if they were not enabled yet:
<br />
<Table variant="compact">
<Thead>
<Tr>
{isSelectingPackage ? <Th>Packages</Th> : <Th>Package groups</Th>}
<Th>Repositories</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
{isSelectingPackage ? (
<Td>{isSelectingPackage?.name}</Td>
) : (
<Td>{isSelectingGroup?.name}</Td>
)}
<Td>
EPEL {getEpelVersionForDistribution(distribution)} Everything
x86_64
</Td>
</Tr>
</Tbody>
</Table>
<br />
To move forward, either add the repos to your image, or go back to
review your package selections.
</Modal>
);
};
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<HTMLInputElement>,
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<number>(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 = (
<Icon status="danger" isInline>
<ExclamationCircleIcon />
</Icon>
);
break;
case differenceInDays <= 365:
icon = (
<Icon status="warning" isInline>
<ExclamationTriangleIcon />
</Icon>
);
break;
case differenceInDays > 365:
icon = (
<Icon status="success" isInline>
<CheckCircleIcon />
</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) => (
<Tbody
key={`${grp.name}-${rowIndex}`}
isExpanded={isGroupExpanded(grp.name)}
>
<Tr data-testid="package-row">
<Td
expand={{
rowIndex: rowIndex,
isExpanded: isGroupExpanded(grp.name),
onToggle: () =>
setGroupsExpanded(grp.name, !isGroupExpanded(grp.name)),
expandId: `${grp.name}-expandable`,
}}
/>
<Td
select={{
isSelected: groups.some((g) => g.name === grp.name),
rowIndex: rowIndex,
onSelect: (event, isSelecting) =>
handleGroupSelect(grp, rowIndex, isSelecting),
}}
/>
<Td>
@{grp.name}
<Popover
minWidth="25rem"
headerContent="Included packages"
bodyContent={
<div
style={
grp.package_list.length > 0
? { height: '40em', overflow: 'scroll' }
: {}
}
>
{grp.package_list.length > 0 ? (
<Table
variant="compact"
data-testid="group-included-packages-table"
>
<Tbody>
{grp.package_list.map((pkg) => (
<Tr key={`details-${pkg}`}>
<Td>{pkg}</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<Content>This group has no packages</Content>
)}
</div>
}
>
<Button
icon={<HelpIcon className="pf-v6-u-ml-xs" />}
variant="plain"
aria-label="About included packages"
component="span"
className="pf-v6-u-p-0"
isInline
/>
</Popover>
</Td>
<Td>N/A</Td>
<Td>N/A</Td>
{grp.repository === 'distro' ? (
<>
<Td>Red Hat</Td>
</>
) : grp.repository === 'custom' ? (
<>
<Td>Third party repository</Td>
</>
) : grp.repository === 'recommended' ? (
<>
<Td>
<Icon status="warning">
<OptimizeIcon />
</Icon>{' '}
EPEL {getEpelVersionForDistribution(distribution)}{' '}
Everything x86_64
</Td>
</>
) : (
<>
<Td className="not-available">Not available</Td>
</>
)}
</Tr>
<Tr isExpanded={isGroupExpanded(grp.name)}>
<Td colSpan={6}>
<ExpandableRowContent>
{
<DescriptionList>
<DescriptionListGroup>
<DescriptionListTerm>
Description
{toggleSelected === 'toggle-selected' && (
<PackageInfoNotAvailablePopover />
)}
</DescriptionListTerm>
<DescriptionListDescription>
{grp.description
? grp.description
: 'Not available'}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
}
</ExpandableRowContent>
</Td>
</Tr>
</Tbody>
))
);
}
if (showPackages) {
rows = rows.concat(
sortedPackages
.slice(computeStart(), computeEnd())
.map((pkg, rowIndex) => (
<Tbody
key={`${pkg.name}-${rowIndex}`}
isExpanded={isPkgExpanded(pkg)}
>
<Tr data-testid="package-row">
<Td
expand={{
rowIndex: rowIndex,
isExpanded: isPkgExpanded(pkg),
onToggle: () => setPkgExpanded(pkg, !isPkgExpanded(pkg)),
expandId: `${pkg.name}-expandable`,
}}
/>
<Td
select={{
isSelected: isPackageSelected(pkg),
rowIndex: rowIndex,
onSelect: (event, isSelecting) =>
handleSelect(pkg, rowIndex, isSelecting),
isDisabled: isSelectDisabled(pkg),
}}
title={
isSelectDisabled(pkg)
? 'Disabled due to the package(s) you selected. You cannot select packages from different application stream versions.'
: ''
}
/>
<Td>{pkg.name}</Td>
<Td>{pkg.stream ? pkg.stream : 'N/A'}</Td>
<Td>{pkg.end_date ? formatDate(pkg.end_date) : 'N/A'}</Td>
{pkg.repository === 'distro' ? (
<>
<Td>Red Hat</Td>
</>
) : pkg.repository === 'custom' ? (
<>
<Td>Third party repository</Td>
</>
) : pkg.repository === 'recommended' ? (
<>
<Td>
<Icon status="warning">
<OptimizeIcon />
</Icon>{' '}
EPEL {getEpelVersionForDistribution(distribution)}{' '}
Everything x86_64
</Td>
</>
) : (
<>
<Td className="not-available">Not available</Td>
</>
)}
</Tr>
<Tr isExpanded={isPkgExpanded(pkg)}>
<Td colSpan={6}>
<ExpandableRowContent>
{
<DescriptionList>
<DescriptionListGroup>
<DescriptionListTerm>
Description
{toggleSelected === 'toggle-selected' && (
<PackageInfoNotAvailablePopover />
)}
</DescriptionListTerm>
<DescriptionListDescription>
{pkg.summary ? pkg.summary : 'Not available'}
</DescriptionListDescription>
</DescriptionListGroup>
</DescriptionList>
}
</ExpandableRowContent>
</Td>
</Tr>
</Tbody>
))
);
}
return rows;
};
const bodyContent = useMemo(() => {
switch (true) {
case debouncedSearchTermLengthOf1 && !debouncedSearchTermIsGroup:
return TooShort();
case (toggleSelected === 'toggle-selected' &&
packages.length === 0 &&
groups.length === 0) ||
(!debouncedSearchTerm && toggleSelected === 'toggle-available'):
return <EmptySearch />;
case (debouncedSearchTerm &&
(isLoadingRecommendedPackages || isLoadingRecommendedGroups) &&
activeTabKey === Repos.OTHER) ||
(debouncedSearchTerm &&
(isLoadingDistroPackages ||
isLoadingCustomPackages ||
isLoadingDistroGroups ||
isLoadingCustomGroups) &&
activeTabKey === Repos.INCLUDED):
return <Searching />;
case debouncedSearchTerm &&
transformedPackages.length === 0 &&
transformedGroups.length === 0 &&
toggleSelected === 'toggle-available':
return <NoResultsFound />;
case debouncedSearchTerm &&
toggleSelected === 'toggle-selected' &&
activeTabKey === Repos.OTHER &&
packages.length > 0 &&
groups.length > 0:
return <TryLookingUnderIncluded />;
default:
return composePkgTable();
}
// 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,
activeTabKey,
transformedPackages,
isSelectingPackage,
recommendedRepositories,
transformedPackages.length,
transformedGroups.length,
expandedPkgs,
expandedGroups,
sortedPackages,
activeSortDirection,
activeSortIndex,
]);
const PackagesTable = () => {
return (
<Table variant="compact" data-testid="packages-table">
<Thead>
<Tr>
<Th aria-label="Expanded" />
<Th aria-label="Selected" />
<Th sort={getSortParams(0)} width={20}>
Name
</Th>
<Th sort={getSortParams(2)} width={20}>
Application stream
</Th>
<Th sort={getSortParams(3)} width={20}>
Retirement date
</Th>
<Th width={20}>Package repository</Th>
</Tr>
</Thead>
{bodyContent}
</Table>
);
};
return (
<>
<RepositoryModal />
<Toolbar>
<Stack>
<ToolbarContent>
<ToolbarItem>
<SearchInput
type="text"
placeholder="Search packages"
aria-label="Search packages"
data-testid="packages-search-input"
value={searchTerm}
onChange={handleSearch}
onClear={() => handleClear()}
/>
</ToolbarItem>
<ToolbarItem>
<ToggleGroup>
<ToggleGroupItem
text="Available"
buttonId="toggle-available"
isSelected={toggleSelected === 'toggle-available'}
onChange={handleFilterToggleClick}
/>
<ToggleGroupItem
text={`Selected${
packages.length + groups.length === 0
? ''
: packages.length + groups.length <= 100
? ` (${packages.length + groups.length})`
: ' (100+)'
}`}
buttonId="toggle-selected"
isSelected={toggleSelected === 'toggle-selected'}
onChange={handleFilterToggleClick}
/>
</ToggleGroup>
</ToolbarItem>
<ToolbarItem variant="pagination">
<Pagination
data-testid="packages-pagination-top"
itemCount={
searchTerm === '' && toggleSelected === 'toggle-available'
? 0
: showPackages && showGroups
? transformedPackages.length + transformedGroups.length
: showPackages
? transformedPackages.length
: transformedGroups.length
}
perPage={perPage}
page={page}
onSetPage={handleSetPage}
onPerPageSelect={handlePerPageSelect}
isCompact
/>
</ToolbarItem>
</ToolbarContent>
<ToolbarContent>
<CustomHelperText
hide={!debouncedSearchTermLengthOf1 || debouncedSearchTermIsGroup}
textValue="The search value must be greater than 1 character"
/>
</ToolbarContent>
</Stack>
</Toolbar>
<Tabs
activeKey={activeTabKey}
onSelect={handleTabClick}
aria-label="Repositories tabs on packages step"
>
<Tab
eventKey="included-repos"
title={<TabTitleText>Included repos</TabTitleText>}
actions={<IncludedReposPopover />}
aria-label="Included repositories"
/>
{!process.env.IS_ON_PREMISE && (
<Tab
eventKey="other-repos"
title={<TabTitleText>Other repos</TabTitleText>}
actions={<OtherReposPopover />}
aria-label="Other repositories"
/>
)}
</Tabs>
<PackagesTable />
<Pagination
data-testid="packages-pagination-bottom"
itemCount={
searchTerm === '' && toggleSelected === 'toggle-available'
? 0
: showPackages && showGroups
? transformedPackages.length + transformedGroups.length
: showPackages
? transformedPackages.length
: transformedGroups.length
}
perPage={perPage}
page={page}
onSetPage={handleSetPage}
onPerPageSelect={handlePerPageSelect}
variant={PaginationVariant.bottom}
/>
</>
);
};
export default Packages;