CreateImageWizardV2: search package groups in distro repositories

By prepending an `@`, users can search for package groups. A single `@`
just lists all groups.
This commit is contained in:
Sanne Raymaekers 2024-05-27 11:21:07 +02:00 committed by Klara Simickova
parent 69367ba1d9
commit 177e4b227c
5 changed files with 402 additions and 119 deletions

View file

@ -12,6 +12,7 @@ const config: ConfigFile = {
'listRepositories',
'listRepositoriesRpms',
'searchRpm',
'searchPackageGroup',
'listFeatures',
'listSnapshotsByDate',
],

View file

@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
import {
Bullseye,
@ -52,6 +52,7 @@ import {
useCreateRepositoryMutation,
useListRepositoriesQuery,
useSearchRpmMutation,
useSearchPackageGroupMutation,
} from '../../../../store/contentSourcesApi';
import { useAppSelector } from '../../../../store/hooks';
import {
@ -59,12 +60,15 @@ import {
useGetArchitecturesQuery,
} from '../../../../store/imageBuilderApi';
import {
removePackage,
selectArchitecture,
selectPackages,
selectGroups,
selectCustomRepositories,
selectDistribution,
addPackage,
removePackage,
addGroup,
removeGroup,
addRecommendedRepository,
removeRecommendedRepository,
selectRecommendedRepositories,
@ -80,6 +84,13 @@ export type IBPackageWithRepositoryInfo = {
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',
@ -108,6 +119,7 @@ const Packages = () => {
const customRepositories = useAppSelector(selectCustomRepositories);
const recommendedRepositories = useAppSelector(selectRecommendedRepositories);
const packages = useAppSelector(selectPackages);
const groups = useAppSelector(selectGroups);
const { data: distroRepositories, isSuccess: isSuccessDistroRepositories } =
useGetArchitecturesQuery({
@ -149,6 +161,7 @@ const Packages = () => {
const debouncedSearchTerm = useDebounce(searchTerm.trim());
const debouncedSearchTermLengthOf1 = debouncedSearchTerm.length === 1;
const debouncedSearchTermIsGroup = debouncedSearchTerm.startsWith('@');
const [
searchRecommendedRpms,
@ -168,10 +181,56 @@ const Packages = () => {
},
] = useSearchRpmMutation();
const [
searchDistroGroups,
{
data: dataDistroGroups,
isSuccess: isSuccessDistroGroups,
isLoading: isLoadingDistroGroups,
},
] = 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: {
@ -227,6 +286,35 @@ const Packages = () => {
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;
}),
},
});
}
}, [
customRepositories,
searchDistroGroups,
debouncedSearchTerm,
toggleSourceRepos,
epelRepoUrlByDistribution,
]);
const EmptySearch = () => {
return (
<Tr>
@ -591,39 +679,27 @@ const Packages = () => {
packages,
toggleSelected,
toggleSourceRepos,
]).sort((a, b) => {
const aPkg = a.name.toLowerCase();
const bPkg = b.name.toLowerCase();
// check exact match first
if (aPkg === debouncedSearchTerm) {
return -1;
]).sort((a, b) => sortfn(a.name, b.name));
const transformedGroups = useMemo(() => {
let transformedDistroGroups: GroupWithRepositoryInfo[] = [];
if (isSuccessDistroGroups) {
transformedDistroGroups = dataDistroGroups!.map((values) => ({
name: values.id!,
description: values.description!,
repository: 'distro',
package_list: values.package_list!,
}));
}
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;
});
return transformedDistroGroups;
}, [
dataDistroGroups,
debouncedSearchTerm,
isSuccessDistroGroups,
groups,
toggleSelected,
toggleSourceRepos,
]).sort((a, b) => sortfn(a.name, b.name));
const handleSearch = async (
event: React.FormEvent<HTMLInputElement>,
@ -665,6 +741,18 @@ const Packages = () => {
}
};
const handleGroupSelect = (
grp: GroupWithRepositoryInfo,
_: number,
isSelecting: boolean
) => {
if (isSelecting) {
dispatch(addGroup(grp));
} else {
dispatch(removeGroup(grp.name));
}
};
const handleFilterToggleClick = (event: React.MouseEvent) => {
const id = event.currentTarget.id;
setPage(1);
@ -766,7 +854,103 @@ const Packages = () => {
};
const composePkgTable = () => {
return transformedPackages
let rows: ReactElement[] = [];
rows = rows.concat(
transformedGroups
.slice(computeStart(), computeEnd())
.map((grp, rowIndex) => (
<Tr key={`${grp.name}-${rowIndex}`} data-testid="package-row">
<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"
bodyContent={
<div
style={
grp.package_list.length > 0
? { height: '40em', overflow: 'scroll' }
: {}
}
>
{grp.package_list.length > 0 ? (
<Table variant="compact">
<Thead>
<Tr>
<Th>Included packages</Th>
</Tr>
</Thead>
<Tbody>
{grp.package_list.map((pkg) => (
<Tr key={`details-${pkg}`}>
<Td>{pkg}</Td>
</Tr>
))}
</Tbody>
</Table>
) : (
<Text>This group has no packages</Text>
)}
</div>
}
>
<Button
variant="plain"
aria-label="About included repositories"
component="span"
className="pf-u-p-0"
isInline
>
<HelpIcon className="pf-u-ml-xs" />
</Button>
</Popover>
</Td>
<Td>
{grp.description ? (
grp.description
) : (
<span className="not-available">Not available</span>
)}
</Td>
{grp.repository === 'distro' ? (
<>
<Td>
<img
src={
'/apps/frontend-assets/red-hat-logos/logo_hat-only.svg'
}
alt="Red Hat logo"
height={RH_ICON_SIZE}
width={RH_ICON_SIZE}
/>{' '}
Red Hat repository
</Td>
<Td>Supported</Td>
</>
) : grp.repository === 'custom' ? (
<>
<Td>Third party repository</Td>
<Td>Not supported</Td>
</>
) : (
<>
<Td className="not-available">Not available</Td>
<Td className="not-available">Not available</Td>
</>
)}
</Tr>
))
);
rows = rows.concat(
transformedPackages
.slice(computeStart(), computeEnd())
.map((pkg, rowIndex) => (
<Tr key={`${pkg.name}-${rowIndex}`} data-testid="package-row">
@ -789,7 +973,15 @@ const Packages = () => {
{pkg.repository === 'distro' ? (
<>
<Td>
<RedHatRepository />
<img
src={
'/apps/frontend-assets/red-hat-logos/logo_hat-only.svg'
}
alt="Red Hat logo"
height={RH_ICON_SIZE}
width={RH_ICON_SIZE}
/>{' '}
Red Hat repository
</Td>
<Td>Supported</Td>
</>
@ -804,8 +996,8 @@ const Packages = () => {
<Icon status="warning">
<OptimizeIcon />
</Icon>{' '}
EPEL {distribution.startsWith('rhel-8') ? '8' : '9'} Everything
x86_64
EPEL {distribution.startsWith('rhel-8') ? '8' : '9'}{' '}
Everything x86_64
</Td>
<Td>Not supported</Td>
</>
@ -816,36 +1008,48 @@ const Packages = () => {
</>
)}
</Tr>
));
))
);
return rows;
};
const bodyContent = useMemo(() => {
switch (true) {
case debouncedSearchTermLengthOf1 && transformedPackages.length === 0:
case debouncedSearchTermLengthOf1 &&
!debouncedSearchTermIsGroup &&
transformedPackages.length === 0 &&
transformedGroups.length === 0:
return TooShort();
case (toggleSelected === 'toggle-selected' && packages.length === 0) ||
case (toggleSelected === 'toggle-selected' &&
packages.length === 0 &&
groups.length === 0) ||
(!debouncedSearchTerm && toggleSelected === 'toggle-available'):
return <EmptySearch />;
case (debouncedSearchTerm &&
isLoadingRecommendedPackages &&
toggleSourceRepos === RepoToggle.OTHER) ||
(debouncedSearchTerm &&
(isLoadingDistroPackages || isLoadingCustomPackages) &&
(isLoadingDistroPackages ||
isLoadingCustomPackages ||
isLoadingDistroGroups) &&
toggleSourceRepos === RepoToggle.INCLUDED):
return <Searching />;
case debouncedSearchTerm &&
transformedPackages.length === 0 &&
transformedGroups.length === 0 &&
toggleSelected === 'toggle-available':
return <NoResultsFound />;
case debouncedSearchTerm &&
toggleSelected === 'toggle-selected' &&
toggleSourceRepos === RepoToggle.OTHER &&
packages.length > 0:
packages.length > 0 &&
groups.length > 0:
return <TryLookingUnderIncluded />;
case debouncedSearchTerm && transformedPackages.length >= 100:
return handleExactMatch();
case (debouncedSearchTerm || toggleSelected === 'toggle-selected') &&
transformedPackages.length < 100:
transformedPackages.length < 100 &&
transformedGroups.length < 100:
return composePkgTable();
default:
return <></>;
@ -857,17 +1061,21 @@ const Packages = () => {
perPage,
debouncedSearchTerm,
debouncedSearchTermLengthOf1,
debouncedSearchTermIsGroup,
isLoadingCustomPackages,
isLoadingDistroPackages,
isLoadingRecommendedPackages,
isSuccessRecommendedPackages,
isLoadingDistroGroups,
packages.length,
groups.length,
toggleSelected,
toggleSourceRepos,
transformedPackages,
isSelectingPackage,
recommendedRepositories,
transformedPackages.length,
transformedGroups.length,
]);
return (
@ -886,7 +1094,10 @@ const Packages = () => {
data-ouia-component-id="packages-search-input"
type="text"
validated={
debouncedSearchTermLengthOf1 ? 'error' : 'default'
debouncedSearchTermLengthOf1 &&
!debouncedSearchTermIsGroup
? 'error'
: 'default'
}
placeholder="Type to search"
aria-label="Search packages"
@ -994,7 +1205,9 @@ const Packages = () => {
</ToolbarItem>
<ToolbarItem variant="pagination">
<Pagination
itemCount={transformedPackages.length}
itemCount={
transformedPackages.length + transformedGroups.length
}
perPage={perPage}
page={page}
onSetPage={handleSetPage}
@ -1005,7 +1218,7 @@ const Packages = () => {
</ToolbarContent>
<ToolbarContent>
<CustomHelperText
hide={!debouncedSearchTermLengthOf1}
hide={!debouncedSearchTermLengthOf1 || debouncedSearchTermIsGroup}
textValue="The search value must be greater than 1 character"
/>
</ToolbarContent>
@ -1030,7 +1243,7 @@ const Packages = () => {
<Tbody>{bodyContent}</Tbody>
</Table>
<Pagination
itemCount={transformedPackages.length}
itemCount={transformedPackages.length + transformedGroups.length}
perPage={perPage}
page={page}
onSetPage={handleSetPage}

View file

@ -242,11 +242,22 @@ export const mapRequestToState = (request: BlueprintResponse): wizardState => {
activationKey: request.customizations.subscription?.['activation-key'],
},
packages:
request.customizations.packages?.map((pkg) => ({
request.customizations.packages
?.filter((pkg) => !pkg.startsWith('@'))
.map((pkg) => ({
name: pkg,
summary: '',
repository: '',
})) || [],
groups:
request.customizations.packages
?.filter((grp) => grp.startsWith('@'))
.map((grp) => ({
name: grp,
description: '',
repository: '',
package_list: [],
})) || [],
stepValidations: {},
};
};

View file

@ -4,6 +4,16 @@ const injectedRtkApi = api.injectEndpoints({
listFeatures: build.query<ListFeaturesApiResponse, ListFeaturesApiArg>({
query: () => ({ url: `/features/` }),
}),
searchPackageGroup: build.mutation<
SearchPackageGroupApiResponse,
SearchPackageGroupApiArg
>({
query: (queryArg) => ({
url: `/package_groups/names`,
method: "POST",
body: queryArg.apiContentUnitSearchRequest,
}),
}),
listRepositories: build.query<
ListRepositoriesApiResponse,
ListRepositoriesApiArg
@ -75,6 +85,12 @@ const injectedRtkApi = api.injectEndpoints({
export { injectedRtkApi as contentSourcesApi };
export type ListFeaturesApiResponse = /** status 200 OK */ ApiFeatureSet;
export type ListFeaturesApiArg = void;
export type SearchPackageGroupApiResponse =
/** status 200 OK */ ApiSearchPackageGroupResponse[];
export type SearchPackageGroupApiArg = {
/** request body */
apiContentUnitSearchRequest: ApiContentUnitSearchRequest;
};
export type ListRepositoriesApiResponse =
/** status 200 OK */ ApiRepositoryCollectionResponseRead;
export type ListRepositoriesApiArg = {
@ -147,6 +163,37 @@ export type ApiFeature = {
export type ApiFeatureSet = {
[key: string]: ApiFeature;
};
export type ApiSearchPackageGroupResponse = {
/** Description of the package group found */
description?: string;
/** Package group ID */
id?: string;
/** Name of package group found */
package_group_name?: string;
/** Package list of the package group found */
package_list?: string[];
};
export type ErrorsHandlerError = {
/** An explanation specific to the problem */
detail?: string;
/** HTTP status code applicable to the error */
status?: number;
/** A summary of the problem */
title?: string;
};
export type ErrorsErrorResponse = {
errors?: ErrorsHandlerError[];
};
export type ApiContentUnitSearchRequest = {
/** Maximum number of records to return for the search */
limit?: number;
/** Search string to search content unit names */
search?: string;
/** URLs of repositories to search */
urls?: string[];
/** List of repository UUIDs to search */
uuids?: string[];
};
export type ApiSnapshotResponse = {
/** Count of each content type */
added_counts?: {
@ -316,17 +363,6 @@ export type ApiRepositoryCollectionResponseRead = {
links?: ApiLinks;
meta?: ApiResponseMetadata;
};
export type ErrorsHandlerError = {
/** An explanation specific to the problem */
detail?: string;
/** HTTP status code applicable to the error */
status?: number;
/** A summary of the problem */
title?: string;
};
export type ErrorsErrorResponse = {
errors?: ErrorsHandlerError[];
};
export type ApiRepositoryRequest = {
/** Architecture to restrict client usage to */
distribution_arch?: string;
@ -375,16 +411,6 @@ export type ApiSearchRpmResponse = {
/** Summary of the package found */
summary?: string;
};
export type ApiContentUnitSearchRequest = {
/** Maximum number of records to return for the search */
limit?: number;
/** Search string to search content unit names */
search?: string;
/** URLs of repositories to search */
urls?: string[];
/** List of repository UUIDs to search */
uuids?: string[];
};
export type ApiSnapshotForDate = {
/** Is the snapshot after the specified date */
is_after?: boolean;
@ -404,6 +430,7 @@ export type ApiListSnapshotByDateRequest = {
};
export const {
useListFeaturesQuery,
useSearchPackageGroupMutation,
useListRepositoriesQuery,
useCreateRepositoryMutation,
useListRepositoriesRpmsQuery,

View file

@ -17,7 +17,10 @@ import {
Partition,
Units,
} from '../Components/CreateImageWizardV2/steps/FileSystem/FileSystemConfiguration';
import { IBPackageWithRepositoryInfo } from '../Components/CreateImageWizardV2/steps/Packages/Packages';
import {
GroupWithRepositoryInfo,
IBPackageWithRepositoryInfo,
} from '../Components/CreateImageWizardV2/steps/Packages/Packages';
import { AwsShareMethod } from '../Components/CreateImageWizardV2/steps/TargetEnvironment/Aws';
import { AzureShareMethod } from '../Components/CreateImageWizardV2/steps/TargetEnvironment/Azure';
import {
@ -90,6 +93,7 @@ export type wizardState = {
recommendedRepositories: ApiRepositoryResponseRead[];
};
packages: IBPackageWithRepositoryInfo[];
groups: GroupWithRepositoryInfo[];
details: {
blueprintName: string;
blueprintDescription: string;
@ -153,6 +157,7 @@ const initialState: wizardState = {
recommendedRepositories: [],
},
packages: [],
groups: [],
details: {
blueprintName: '',
blueprintDescription: '',
@ -276,6 +281,10 @@ export const selectPackages = (state: RootState) => {
return state.wizard.packages;
};
export const selectGroups = (state: RootState) => {
return state.wizard.groups;
};
export const selectBlueprintName = (state: RootState) => {
return state.wizard.details.blueprintName;
};
@ -601,6 +610,26 @@ export const wizardSlice = createSlice({
1
);
},
addGroup: (state, action: PayloadAction<GroupWithRepositoryInfo>) => {
const existingGrpIndex = state.groups.findIndex(
(grp) => grp.name === action.payload.name
);
if (existingGrpIndex !== -1) {
state.groups[existingGrpIndex] = action.payload;
} else {
state.groups.push(action.payload);
}
},
removeGroup: (
state,
action: PayloadAction<GroupWithRepositoryInfo['name']>
) => {
state.groups.splice(
state.groups.findIndex((grp) => grp.name === action.payload),
1
);
},
changeBlueprintName: (state, action: PayloadAction<string>) => {
state.details.blueprintName = action.payload;
},
@ -684,6 +713,8 @@ export const {
removeRecommendedRepository,
addPackage,
removePackage,
addGroup,
removeGroup,
changeBlueprintName,
changeBlueprintDescription,
loadWizardState,