From 177e4b227cf5250e22183d14efa7a17ab7c10584 Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Mon, 27 May 2024 11:21:07 +0200 Subject: [PATCH] CreateImageWizardV2: search package groups in distro repositories By prepending an `@`, users can search for package groups. A single `@` just lists all groups. --- api/config/contentSources.ts | 1 + .../steps/Packages/Packages.tsx | 397 ++++++++++++++---- .../utilities/requestMapper.ts | 21 +- src/store/contentSourcesApi.ts | 69 ++- src/store/wizardSlice.ts | 33 +- 5 files changed, 402 insertions(+), 119 deletions(-) diff --git a/api/config/contentSources.ts b/api/config/contentSources.ts index 2865b338..ffd7b7ab 100644 --- a/api/config/contentSources.ts +++ b/api/config/contentSources.ts @@ -12,6 +12,7 @@ const config: ConfigFile = { 'listRepositories', 'listRepositoriesRpms', 'searchRpm', + 'searchPackageGroup', 'listFeatures', 'listSnapshotsByDate', ], diff --git a/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx b/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx index ce8c9dc9..968e0b52 100644 --- a/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx +++ b/src/Components/CreateImageWizardV2/steps/Packages/Packages.tsx @@ -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 ( @@ -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, @@ -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,86 +854,202 @@ const Packages = () => { }; const composePkgTable = () => { - return transformedPackages - .slice(computeStart(), computeEnd()) - .map((pkg, rowIndex) => ( - - p.name === pkg.name), - rowIndex: rowIndex, - onSelect: (event, isSelecting) => - handleSelect(pkg, rowIndex, isSelecting), - }} - /> - {pkg.name} - - {pkg.summary ? ( - pkg.summary + let rows: ReactElement[] = []; + rows = rows.concat( + transformedGroups + .slice(computeStart(), computeEnd()) + .map((grp, rowIndex) => ( + + g.name === grp.name), + rowIndex: rowIndex, + onSelect: (event, isSelecting) => + handleGroupSelect(grp, rowIndex, isSelecting), + }} + /> + + @{grp.name} + 0 + ? { height: '40em', overflow: 'scroll' } + : {} + } + > + {grp.package_list.length > 0 ? ( + + + + + + + + {grp.package_list.map((pkg) => ( + + + + ))} + +
Included packages
{pkg}
+ ) : ( + This group has no packages + )} + + } + > + +
+ + + {grp.description ? ( + grp.description + ) : ( + Not available + )} + + {grp.repository === 'distro' ? ( + <> + + Red Hat logo{' '} + Red Hat repository + + Supported + + ) : grp.repository === 'custom' ? ( + <> + Third party repository + Not supported + ) : ( - Not available + <> + Not available + Not available + )} - - {pkg.repository === 'distro' ? ( - <> - - - - Supported - - ) : pkg.repository === 'custom' ? ( - <> - Third party repository - Not supported - - ) : pkg.repository === 'recommended' ? ( - <> - - - - {' '} - EPEL {distribution.startsWith('rhel-8') ? '8' : '9'} Everything - x86_64 - - Not supported - - ) : ( - <> - Not available - Not available - - )} - - )); + + )) + ); + + rows = rows.concat( + transformedPackages + .slice(computeStart(), computeEnd()) + .map((pkg, rowIndex) => ( + + p.name === pkg.name), + rowIndex: rowIndex, + onSelect: (event, isSelecting) => + handleSelect(pkg, rowIndex, isSelecting), + }} + /> + {pkg.name} + + {pkg.summary ? ( + pkg.summary + ) : ( + Not available + )} + + {pkg.repository === 'distro' ? ( + <> + + Red Hat logo{' '} + Red Hat repository + + Supported + + ) : pkg.repository === 'custom' ? ( + <> + Third party repository + Not supported + + ) : pkg.repository === 'recommended' ? ( + <> + + + + {' '} + EPEL {distribution.startsWith('rhel-8') ? '8' : '9'}{' '} + Everything x86_64 + + Not supported + + ) : ( + <> + Not available + Not available + + )} + + )) + ); + return rows; }; const bodyContent = useMemo(() => { switch (true) { - case debouncedSearchTermLengthOf1 && 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 ; case (debouncedSearchTerm && isLoadingRecommendedPackages && toggleSourceRepos === RepoToggle.OTHER) || (debouncedSearchTerm && - (isLoadingDistroPackages || isLoadingCustomPackages) && + (isLoadingDistroPackages || + isLoadingCustomPackages || + isLoadingDistroGroups) && toggleSourceRepos === RepoToggle.INCLUDED): return ; case debouncedSearchTerm && transformedPackages.length === 0 && + transformedGroups.length === 0 && toggleSelected === 'toggle-available': return ; case debouncedSearchTerm && toggleSelected === 'toggle-selected' && toggleSourceRepos === RepoToggle.OTHER && - packages.length > 0: + packages.length > 0 && + groups.length > 0: return ; 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 = () => { { @@ -1030,7 +1243,7 @@ const Packages = () => { {bodyContent} { activationKey: request.customizations.subscription?.['activation-key'], }, packages: - request.customizations.packages?.map((pkg) => ({ - name: pkg, - summary: '', - repository: '', - })) || [], + 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: {}, }; }; diff --git a/src/store/contentSourcesApi.ts b/src/store/contentSourcesApi.ts index 5d274017..846f5827 100644 --- a/src/store/contentSourcesApi.ts +++ b/src/store/contentSourcesApi.ts @@ -4,6 +4,16 @@ const injectedRtkApi = api.injectEndpoints({ listFeatures: build.query({ 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, diff --git a/src/store/wizardSlice.ts b/src/store/wizardSlice.ts index 739e2706..b1e19494 100644 --- a/src/store/wizardSlice.ts +++ b/src/store/wizardSlice.ts @@ -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) => { + 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 + ) => { + state.groups.splice( + state.groups.findIndex((grp) => grp.name === action.payload), + 1 + ); + }, changeBlueprintName: (state, action: PayloadAction) => { state.details.blueprintName = action.payload; }, @@ -684,6 +713,8 @@ export const { removeRecommendedRepository, addPackage, removePackage, + addGroup, + removeGroup, changeBlueprintName, changeBlueprintDescription, loadWizardState,