debian-image-builder-frontend/src/Components/CreateImageWizard/steps/Packages/Packages.tsx
Michal Gold a5aa15cbcb Wizard: Resolve row reordering issue on selection and expansion
- Fix issue when clicking the expandable arrow or selecting a package checkbox in the Packages step it caused unexpected row reordering.
- Updated sorting logic to ensure that selecting a package with a specific stream groups all related module streams together at the top.
- Ensured that rows expand in place and selection does not affect row position.
- Add unit test as well
2025-08-21 10:17:06 +00:00

1567 lines
46 KiB
TypeScript

import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import {
Bullseye,
Button,
Content,
DescriptionList,
DescriptionListDescription,
DescriptionListGroup,
DescriptionListTerm,
EmptyState,
EmptyStateActions,
EmptyStateBody,
EmptyStateFooter,
EmptyStateVariant,
Icon,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Pagination,
PaginationVariant,
Popover,
SearchInput,
Spinner,
Stack,
Tab,
Tabs,
TabTitleText,
ToggleGroup,
ToggleGroupItem,
Toolbar,
ToolbarContent,
ToolbarItem,
} from '@patternfly/react-core';
import {
CheckCircleIcon,
ExclamationCircleIcon,
ExclamationTriangleIcon,
ExternalLinkAltIcon,
HelpIcon,
SearchIcon,
} from '@patternfly/react-icons';
import {
ExpandableRowContent,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
} from '@patternfly/react-table';
import { orderBy } from 'lodash';
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,
ApiSearchRpmResponse,
useCreateRepositoryMutation,
useGetTemplateQuery,
useListRepositoriesQuery,
useSearchPackageGroupMutation,
useSearchRpmMutation,
} from '../../../../store/contentSourcesApi';
import { useAppSelector } from '../../../../store/hooks';
import { Package } from '../../../../store/imageBuilderApi';
import {
addGroup,
addModule,
addPackage,
addRecommendedRepository,
removeGroup,
removeModule,
removePackage,
removeRecommendedRepository,
selectArchitecture,
selectCustomRepositories,
selectDistribution,
selectGroups,
selectModules,
selectPackages,
selectRecommendedRepositories,
selectTemplate,
} 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 template = useAppSelector(selectTemplate);
const { data: templateData } = useGetTemplateQuery({
uuid: template,
});
const {
data: { data: reposInTemplate = [] } = {},
isLoading: isLoadingReposInTemplate,
} = useListRepositoriesQuery({
contentType: 'rpm',
limit: 100,
offset: 0,
uuid:
templateData && templateData.repository_uuids
? templateData.repository_uuids.join(',')
: '',
});
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:
template === ''
? distroRepositories
?.filter((archItem) => {
return archItem.arch === arch;
})[0]
.repositories.flatMap((repo) => {
if (!repo.baseurl) {
throw new Error(`Repository ${repo} missing baseurl`);
}
return repo.baseurl;
})
: reposInTemplate
.filter((r) => r.org_id === '-1' && !!r.url)
.flatMap((r) =>
r.url!.endsWith('/') ? r.url!.slice(0, -1) : r.url!,
),
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,
distroRepositories,
arch,
template,
distribution,
debouncedSearchTermIsGroup,
]);
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,
debouncedSearchTermIsGroup,
arch,
distroRepositories,
isSuccessDistroRepositories,
isSuccessEpelRepo,
]);
const EmptySearch = () => {
return (
<Tbody>
<Tr>
<Td colSpan={5}>
<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={5}>
<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={5}>
<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={5}>
<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={5}>
<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={5}>
<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
isOpen={isRepoModalOpen}
onClose={handleCloseModalToggle}
width='50%'
>
<ModalHeader
title='Custom repositories will be added to your image'
titleIconVariant='warning'
/>
<ModalBody>
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.
</ModalBody>
<ModalFooter>
<Button
key='add'
variant='primary'
isLoading={createLoading}
isDisabled={createLoading}
onClick={handleConfirmModalToggle}
>
Add listed repositories
</Button>
<Button key='back' variant='link' onClick={handleCloseModalToggle}>
Back
</Button>
</ModalFooter>
</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,
);
}
let 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 = orderBy(
unpackedData,
[
'name',
(pkg) => pkg.stream || '',
(pkg) => pkg.repository || '',
(pkg) => pkg.module_name || '',
],
['asc', 'desc', 'asc', 'asc'],
);
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 [];
}
}
}, [
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');
setPage(1);
};
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 || '');
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 getPackageUniqueKey = (pkg: IBPackageWithRepositoryInfo): string => {
try {
if (!pkg || !pkg.name) {
return `invalid_${Date.now()}`;
}
return `${pkg.name}_${pkg.stream || 'none'}_${pkg.module_name || 'none'}_${pkg.repository || 'unknown'}`;
} catch {
return `error_${Date.now()}`;
}
};
const initialExpandedPkgs: string[] = [];
const [expandedPkgs, setExpandedPkgs] = useState(initialExpandedPkgs);
const setPkgExpanded = (
pkg: IBPackageWithRepositoryInfo,
isExpanding: boolean,
) =>
setExpandedPkgs((prevExpanded) => {
const pkgKey = getPackageUniqueKey(pkg);
const otherExpandedPkgs = prevExpanded.filter((key) => key !== pkgKey);
return isExpanding ? [...otherExpandedPkgs, pkgKey] : otherExpandedPkgs;
});
const isPkgExpanded = (pkg: IBPackageWithRepositoryInfo) =>
expandedPkgs.includes(getPackageUniqueKey(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 sortedPackages = useMemo(() => {
if (!transformedPackages || !Array.isArray(transformedPackages)) {
return [];
}
return orderBy(
transformedPackages,
[
// Active stream packages first (if activeStream is set)
(pkg) => (activeStream && pkg.stream === activeStream ? 0 : 1),
// Then by name
'name',
// Then by stream version (descending)
(pkg) => {
if (!pkg.stream) return '';
const parts = pkg.stream
.split('.')
.map((part) => parseInt(part, 10) || 0);
// Convert to string with zero-padding for proper sorting
return parts.map((p) => p.toString().padStart(10, '0')).join('.');
},
// Then by end date (nulls last)
(pkg) => pkg.end_date || '9999-12-31',
// Then by repository
(pkg) => pkg.repository || '',
// Finally by module name
(pkg) => pkg.module_name || '',
],
['asc', 'asc', 'desc', 'asc', 'asc', 'asc'],
);
}, [transformedPackages, activeStream]);
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 && p.stream === pkg.stream) &&
!isModuleWithSameName;
}
if (pkg.type === 'module') {
// the package is selected if its module stream matches one in enabled_modules
isSelected =
packages.some((p) => p.name === pkg.name && p.stream === pkg.stream) &&
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}-${grp.repository || 'default'}`}
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>
</Tr>
<Tr isExpanded={isGroupExpanded(grp.name)}>
<Td colSpan={5}>
<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}-${pkg.stream || 'default'}-${pkg.module_name || pkg.name}`}
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>
</Tr>
<Tr isExpanded={isPkgExpanded(pkg)}>
<Td colSpan={5}>
<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 ||
isLoadingReposInTemplate ||
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,
template,
]);
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={30}>
Name
</Th>
<Th sort={getSortParams(2)} width={20}>
Application stream
</Th>
<Th sort={getSortParams(3)} width={30}>
Retirement date
</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;