Wizard: Add 3rd Party Repositories
Adds support for 3rd party repositories using the Red Hat Insights Repositories app on console.redhat.com. The packages step has been refactored heavily to reduce the bug surface area and improve its reusability (it is now used in two Wizard steps). New features related to the Repositories app are currently only exposed in stage. Because stage and production are quite divergent (they have different steps, for instance) there are separate test suites for the production and stage versions of the Wizard. When these features are moved into production, the two test suites can be merged into one.
This commit is contained in:
parent
3cad570606
commit
5adc0e7d4a
16 changed files with 2227 additions and 342 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
|
||||
import { addNotification } from '@redhat-cloud-services/frontend-components-notifications/redux';
|
||||
|
|
@ -14,7 +14,9 @@ import {
|
|||
imageOutput,
|
||||
msAzureTarget,
|
||||
packages,
|
||||
packagesContentSources,
|
||||
registration,
|
||||
repositories,
|
||||
review,
|
||||
} from './steps';
|
||||
import {
|
||||
|
|
@ -25,7 +27,9 @@ import {
|
|||
import './CreateImageWizard.scss';
|
||||
import api from '../../api';
|
||||
import { UNIT_GIB, UNIT_KIB, UNIT_MIB } from '../../constants';
|
||||
import { getDistroRepoUrls } from '../../repos';
|
||||
import { composeAdded } from '../../store/composesSlice';
|
||||
import { fetchRepositories } from '../../store/repositoriesSlice';
|
||||
import isRhel from '../../Utilities/isRhel';
|
||||
import { resolveRelPath } from '../../Utilities/path';
|
||||
import DocumentationButton from '../sharedComponents/DocumentationButton';
|
||||
|
|
@ -41,6 +45,12 @@ const onSave = (values) => {
|
|||
packages: values['selected-packages']?.map((p) => p.name),
|
||||
};
|
||||
|
||||
if (values['third-party-repositories']?.length > 0) {
|
||||
customizations['payload_repositories'] = [
|
||||
...values['third-party-repositories'],
|
||||
];
|
||||
}
|
||||
|
||||
if (values['register-system'] === 'register-now-insights') {
|
||||
customizations.subscription = {
|
||||
'activation-key': values['subscription-activation-key'],
|
||||
|
|
@ -230,14 +240,12 @@ const parseSizeUnit = (bytesize) => {
|
|||
return [size, unit];
|
||||
};
|
||||
|
||||
const getPackageDescription = async (release, arch, packageName) => {
|
||||
const getPackageDescription = async (release, arch, repoUrls, packageName) => {
|
||||
let pack;
|
||||
// if the env is stage beta then use content-sources api
|
||||
// else use image-builder api
|
||||
if (!insights.chrome.isProd() && insights.chrome.isBeta()) {
|
||||
const args = [release, packageName];
|
||||
const data = await api.getPackagesContentSources(...args);
|
||||
|
||||
const data = await api.getPackagesContentSources(repoUrls, packageName);
|
||||
pack = data.find((pack) => packageName === pack.name);
|
||||
} else {
|
||||
const args = [release, arch, packageName];
|
||||
|
|
@ -327,10 +335,21 @@ const requestToState = (composeRequest) => {
|
|||
// customizations
|
||||
// packages
|
||||
let packs = [];
|
||||
|
||||
const distro = composeRequest?.distribution;
|
||||
const distroRepoUrls = getDistroRepoUrls(distro);
|
||||
const payloadRepositories =
|
||||
composeRequest?.customizations?.payload_repositories?.map(
|
||||
(repo) => repo.baseurl
|
||||
);
|
||||
const repoUrls = [...distroRepoUrls];
|
||||
payloadRepositories ? repoUrls.push(...payloadRepositories) : null;
|
||||
|
||||
composeRequest?.customizations?.packages?.forEach(async (packName) => {
|
||||
const packageDescription = await getPackageDescription(
|
||||
composeRequest?.distribution,
|
||||
distro,
|
||||
imageRequest?.architecture,
|
||||
repoUrls,
|
||||
packName
|
||||
);
|
||||
const pack = {
|
||||
|
|
@ -341,6 +360,16 @@ const requestToState = (composeRequest) => {
|
|||
});
|
||||
formState['selected-packages'] = packs;
|
||||
|
||||
// repositories
|
||||
// 'payload-repositories' is treated as read-only and is used to populate
|
||||
// the table in the repositories table
|
||||
formState['payload-repositories'] =
|
||||
composeRequest?.customizations?.payload_repositories;
|
||||
// 'third-party-repositories' is mutable and is used to generate the request
|
||||
// sent to image-builder
|
||||
formState['third-party-repositories'] =
|
||||
composeRequest?.customizations?.payload_repositories;
|
||||
|
||||
// filesystem
|
||||
const fs = composeRequest?.customizations?.filesystem;
|
||||
if (fs) {
|
||||
|
|
@ -392,7 +421,8 @@ const formStepHistory = (composeRequest) => {
|
|||
if (composeRequest) {
|
||||
const imageRequest = composeRequest.image_requests[0];
|
||||
const uploadRequest = imageRequest.upload_request;
|
||||
let steps = ['image-output'];
|
||||
// the order of steps must match the order of the steps in the Wizard
|
||||
const steps = ['image-output'];
|
||||
|
||||
if (uploadRequest.type === 'aws') {
|
||||
steps.push('aws-target-env');
|
||||
|
|
@ -406,11 +436,19 @@ const formStepHistory = (composeRequest) => {
|
|||
steps.push('registration');
|
||||
}
|
||||
|
||||
steps = steps.concat([
|
||||
'File system configuration',
|
||||
'packages',
|
||||
'image-name',
|
||||
]);
|
||||
if (!insights.chrome.isProd() && insights.chrome.isBeta()) {
|
||||
steps.push('File system configuration', 'packages', 'repositories');
|
||||
|
||||
const thirdPartyRepositories =
|
||||
composeRequest.customizations?.payload_repositories;
|
||||
if (thirdPartyRepositories) {
|
||||
steps.push('packages-content-sources');
|
||||
}
|
||||
} else {
|
||||
steps.push('File system configuration', 'packages');
|
||||
}
|
||||
|
||||
steps.push('image-name');
|
||||
|
||||
return steps;
|
||||
} else {
|
||||
|
|
@ -429,6 +467,12 @@ const CreateImageWizard = () => {
|
|||
|
||||
const handleClose = () => navigate(resolveRelPath(''));
|
||||
|
||||
useEffect(() => {
|
||||
if (!insights.chrome.isProd() && insights.chrome.isBeta()) {
|
||||
dispatch(fetchRepositories());
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ImageCreator
|
||||
onClose={handleClose}
|
||||
|
|
@ -499,7 +543,11 @@ const CreateImageWizard = () => {
|
|||
},
|
||||
showTitles: true,
|
||||
title: 'Create image',
|
||||
crossroads: ['target-environment', 'release'],
|
||||
crossroads: [
|
||||
'target-environment',
|
||||
'release',
|
||||
'third-party-repositories',
|
||||
],
|
||||
description: (
|
||||
<>
|
||||
Image builder allows you to create a custom image and push it to
|
||||
|
|
@ -515,6 +563,8 @@ const CreateImageWizard = () => {
|
|||
msAzureTarget,
|
||||
registration,
|
||||
packages,
|
||||
packagesContentSources,
|
||||
repositories,
|
||||
fileSystemConfiguration,
|
||||
imageName,
|
||||
review,
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ import CentOSAcknowledgement from './formComponents/CentOSAcknowledgement';
|
|||
import FileSystemConfigToggle from './formComponents/FileSystemConfigToggle';
|
||||
import FileSystemConfiguration from './formComponents/FileSystemConfiguration';
|
||||
import ImageOutputReleaseSelect from './formComponents/ImageOutputReleaseSelect';
|
||||
import Packages from './formComponents/Packages';
|
||||
import {
|
||||
ContentSourcesPackages,
|
||||
RedHatPackages,
|
||||
} from './formComponents/Packages';
|
||||
import RadioWithPopover from './formComponents/RadioWithPopover';
|
||||
import RegistrationKeyInformation from './formComponents/RegistrationKeyInformation';
|
||||
import Repositories from './formComponents/Repositories';
|
||||
import Review from './formComponents/ReviewStep';
|
||||
import TargetEnvironment from './formComponents/TargetEnvironment';
|
||||
|
||||
|
|
@ -46,9 +50,12 @@ const ImageCreator = ({
|
|||
output: TargetEnvironment,
|
||||
select: Select,
|
||||
'package-selector': {
|
||||
component: Packages,
|
||||
component: RedHatPackages,
|
||||
defaultArch,
|
||||
},
|
||||
'package-selector-content-sources': {
|
||||
component: ContentSourcesPackages,
|
||||
},
|
||||
'radio-popover': RadioWithPopover,
|
||||
'azure-auth-button': AzureAuthButton,
|
||||
'activation-keys': ActivationKeys,
|
||||
|
|
@ -57,6 +64,7 @@ const ImageCreator = ({
|
|||
'file-system-configuration': FileSystemConfiguration,
|
||||
'image-output-release-select': ImageOutputReleaseSelect,
|
||||
'centos-acknowledgement': CentOSAcknowledgement,
|
||||
'repositories-table': Repositories,
|
||||
...customComponentMapper,
|
||||
}}
|
||||
onCancel={onClose}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import useFieldApi from '@data-driven-forms/react-form-renderer/use-field-api';
|
||||
import useFormApi from '@data-driven-forms/react-form-renderer/use-form-api';
|
||||
import {
|
||||
DualListSelector,
|
||||
|
|
@ -21,34 +26,80 @@ import {
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import api from '../../../api';
|
||||
import { repos } from '../../../repos';
|
||||
|
||||
// the fields isHidden and isSelected should not be included in the package list sent for image creation
|
||||
const removePackagesDisplayFields = (packages) =>
|
||||
packages.map((pack) => ({
|
||||
name: pack.name,
|
||||
summary: pack.summary,
|
||||
}));
|
||||
export const RedHatPackages = ({ defaultArch }) => {
|
||||
const { getState } = useFormApi();
|
||||
|
||||
const Packages = ({ defaultArch, ...props }) => {
|
||||
const getAllPackages = async (packagesSearchName) => {
|
||||
// if the env is stage beta then use content-sources api
|
||||
// else use image-builder api
|
||||
if (!insights.chrome.isProd() && insights.chrome.isBeta()) {
|
||||
const distribution = getState()?.values?.release;
|
||||
const repoUrls = repos[distribution].map((repo) => repo.url);
|
||||
return await api.getPackagesContentSources(repoUrls, packagesSearchName);
|
||||
} else {
|
||||
const args = [
|
||||
getState()?.values?.release,
|
||||
getState()?.values?.architecture || defaultArch,
|
||||
packagesSearchName,
|
||||
];
|
||||
let { data, meta } = await api.getPackages(...args);
|
||||
if (data?.length === meta.count) {
|
||||
return data;
|
||||
} else if (data) {
|
||||
({ data } = await api.getPackages(...args, meta.count));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return <Packages getAllPackages={getAllPackages} />;
|
||||
};
|
||||
|
||||
export const ContentSourcesPackages = () => {
|
||||
const { getState } = useFormApi();
|
||||
|
||||
const getAllPackages = async (packagesSearchName) => {
|
||||
const repos = getState()?.values?.['third-party-repositories'];
|
||||
const repoUrls = repos?.map((repo) => repo.baseurl);
|
||||
return await api.getPackagesContentSources(repoUrls, packagesSearchName);
|
||||
};
|
||||
|
||||
return <Packages getAllPackages={getAllPackages} />;
|
||||
};
|
||||
|
||||
const Packages = ({ getAllPackages }) => {
|
||||
const { change, getState } = useFormApi();
|
||||
const { input } = useFieldApi(props);
|
||||
const [packagesSearchName, setPackagesSearchName] = useState(undefined);
|
||||
const [filterAvailable, setFilterAvailable] = useState(undefined);
|
||||
const [filterChosen, setFilterChosen] = useState(undefined);
|
||||
const [packagesAvailable, setPackagesAvailable] = useState([]);
|
||||
const [packagesAvailableFound, setPackagesAvailableFound] = useState(true);
|
||||
const [packagesChosen, setPackagesChosen] = useState([]);
|
||||
const [packagesChosenFound, setPackagesChosenFound] = useState(true);
|
||||
const [filterChosen, setFilterChosen] = useState('');
|
||||
const [chosenPackages, setChosenPackages] = useState({});
|
||||
const [focus, setFocus] = useState('');
|
||||
const selectedPackages = getState()?.values?.['selected-packages'];
|
||||
const [availablePackages, setAvailablePackages] = useState(undefined);
|
||||
const [selectedAvailablePackages, setSelectedAvailablePackages] = useState(
|
||||
new Set()
|
||||
);
|
||||
const [selectedChosenPackages, setSelectedChosenPackages] = useState(
|
||||
new Set()
|
||||
);
|
||||
const firstInputElement = useRef(null);
|
||||
|
||||
// this effect only triggers on mount
|
||||
useEffect(() => {
|
||||
const selectedPackages = getState()?.values?.['selected-packages'];
|
||||
if (selectedPackages) {
|
||||
setPackagesChosen(selectedPackages);
|
||||
const newChosenPackages = {};
|
||||
for (const pkg of selectedPackages) {
|
||||
newChosenPackages[pkg.name] = pkg;
|
||||
}
|
||||
setChosenPackages(newChosenPackages);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
firstInputElement.current?.focus();
|
||||
}, []);
|
||||
|
||||
const searchResultsComparator = useCallback((searchTerm) => {
|
||||
return (a, b) => {
|
||||
a = a.name.toLowerCase();
|
||||
|
|
@ -86,92 +137,44 @@ const Packages = ({ defaultArch, ...props }) => {
|
|||
};
|
||||
});
|
||||
|
||||
const setPackagesAvailableSorted = (
|
||||
packageList,
|
||||
filter = filterAvailable
|
||||
) => {
|
||||
const sortResults = packageList.sort(searchResultsComparator(filter));
|
||||
setPackagesAvailable(sortResults);
|
||||
};
|
||||
|
||||
const setPackagesChosenSorted = (packageList) => {
|
||||
const sortResults = packageList.sort(searchResultsComparator(filterChosen));
|
||||
setPackagesChosen(sortResults);
|
||||
};
|
||||
|
||||
// filter the packages by name
|
||||
const filterPackagesAvailable = (packageList) => {
|
||||
return packageList.filter((availablePackage) => {
|
||||
// returns true if no packages in the available or chosen list have the same name
|
||||
return !packagesChosen.some(
|
||||
(chosenPackage) => availablePackage.name === chosenPackage.name
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getAllPackages = async () => {
|
||||
// if the env is stage beta then use content-sources api
|
||||
// else use image-builder api
|
||||
if (!insights.chrome.isProd() && insights.chrome.isBeta()) {
|
||||
const args = [getState()?.values?.release, packagesSearchName];
|
||||
return await api.getPackagesContentSources(...args);
|
||||
} else {
|
||||
const args = [
|
||||
getState()?.values?.release,
|
||||
getState()?.values?.architecture || defaultArch,
|
||||
packagesSearchName,
|
||||
];
|
||||
let { data, meta } = await api.getPackages(...args);
|
||||
if (data?.length === meta.count) {
|
||||
return data;
|
||||
} else if (data) {
|
||||
({ data } = await api.getPackages(...args, meta.count));
|
||||
return data;
|
||||
}
|
||||
const availablePackagesDisplayList = useMemo(() => {
|
||||
if (availablePackages === undefined) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
const availablePackagesList = Object.values(availablePackages).sort(
|
||||
searchResultsComparator(packagesSearchName)
|
||||
);
|
||||
return availablePackagesList;
|
||||
}, [availablePackages]);
|
||||
|
||||
const chosenPackagesDisplayList = useMemo(() => {
|
||||
const chosenPackagesList = Object.values(chosenPackages)
|
||||
.filter((pkg) => (pkg.name.includes(filterChosen) ? true : false))
|
||||
.sort(searchResultsComparator(filterChosen));
|
||||
return chosenPackagesList;
|
||||
}, [chosenPackages, filterChosen]);
|
||||
|
||||
// call api to list available packages
|
||||
const handlePackagesAvailableSearch = async () => {
|
||||
setFilterAvailable(packagesSearchName);
|
||||
|
||||
const packageList = await getAllPackages();
|
||||
const handleAvailablePackagesSearch = async () => {
|
||||
const packageList = await getAllPackages(packagesSearchName);
|
||||
// If no packages are found, Image Builder returns null, while
|
||||
// Content Sources returns an empty array [].
|
||||
if (packageList) {
|
||||
const packagesAvailableFiltered = filterPackagesAvailable(packageList);
|
||||
setPackagesAvailableSorted(packagesAvailableFiltered, packagesSearchName);
|
||||
setPackagesAvailableFound(
|
||||
packagesAvailableFiltered.length ? true : false
|
||||
);
|
||||
} else {
|
||||
setPackagesAvailable([]);
|
||||
setPackagesAvailableFound(false);
|
||||
}
|
||||
};
|
||||
|
||||
// filter displayed selected packages
|
||||
const handlePackagesChosenSearch = (val) => {
|
||||
let found = false;
|
||||
const filteredPackagesChosen = packagesChosen.map((pack) => {
|
||||
if (!pack.name.includes(val)) {
|
||||
pack.isHidden = true;
|
||||
} else {
|
||||
pack.isHidden = false;
|
||||
found = true;
|
||||
const newAvailablePackages = {};
|
||||
for (const pkg of packageList) {
|
||||
newAvailablePackages[pkg.name] = pkg;
|
||||
}
|
||||
|
||||
return pack;
|
||||
});
|
||||
|
||||
setFilterChosen(val);
|
||||
setPackagesChosenFound(found);
|
||||
setPackagesChosenSorted(filteredPackagesChosen);
|
||||
setAvailablePackages(newAvailablePackages);
|
||||
} else {
|
||||
setAvailablePackages([]);
|
||||
}
|
||||
};
|
||||
|
||||
const keydownHandler = (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (focus === 'available') {
|
||||
event.stopPropagation();
|
||||
handlePackagesAvailableSearch();
|
||||
handleAvailablePackagesSearch();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -184,145 +187,64 @@ const Packages = ({ defaultArch, ...props }) => {
|
|||
};
|
||||
});
|
||||
|
||||
const areFound = (filter, packageList) => {
|
||||
if (filter === undefined) {
|
||||
return true;
|
||||
} else if (packageList.some((pack) => pack.name.includes(filter))) {
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isHidden = (filter, pack) =>
|
||||
filter && !pack.name.includes(filter) ? true : false;
|
||||
|
||||
const updateState = (updatedPackagesAvailable, updatedPackagesChosen) => {
|
||||
setPackagesChosenSorted(updatedPackagesChosen);
|
||||
setPackagesAvailableSorted(updatedPackagesAvailable);
|
||||
setPackagesAvailableFound(
|
||||
areFound(filterAvailable, updatedPackagesAvailable)
|
||||
);
|
||||
setPackagesChosenFound(areFound(filterChosen, updatedPackagesChosen));
|
||||
// set the steps field to the current chosen packages list
|
||||
change(input.name, removePackagesDisplayFields(updatedPackagesChosen));
|
||||
const updateState = (newChosenPackages) => {
|
||||
setSelectedAvailablePackages(new Set());
|
||||
setSelectedChosenPackages(new Set());
|
||||
setChosenPackages(newChosenPackages);
|
||||
change('selected-packages', Object.values(newChosenPackages));
|
||||
};
|
||||
|
||||
const moveSelectedToChosen = () => {
|
||||
const newPackagesChosen = [];
|
||||
|
||||
const updatedPackagesAvailable = packagesAvailable.filter((pack) => {
|
||||
if (pack.selected) {
|
||||
pack.selected = false;
|
||||
pack.isHidden = isHidden(filterChosen, pack);
|
||||
newPackagesChosen.push(pack);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const updatedPackagesChosen = [...newPackagesChosen, ...packagesChosen];
|
||||
|
||||
updateState(updatedPackagesAvailable, updatedPackagesChosen);
|
||||
};
|
||||
|
||||
const moveSelectedToAvailable = () => {
|
||||
const newPackagesAvailable = [];
|
||||
|
||||
const updatedPackagesChosen = packagesChosen.filter((pack) => {
|
||||
if (pack.selected) {
|
||||
pack.selected = false;
|
||||
pack.isHidden = false;
|
||||
pack.name.includes(filterAvailable)
|
||||
? newPackagesAvailable.push(pack)
|
||||
: null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const updatedPackagesAvailable = [
|
||||
...newPackagesAvailable,
|
||||
...packagesAvailable,
|
||||
];
|
||||
|
||||
updateState(updatedPackagesAvailable, updatedPackagesChosen);
|
||||
const newChosenPackages = { ...chosenPackages };
|
||||
for (const pkgName of selectedAvailablePackages) {
|
||||
newChosenPackages[pkgName] = { ...availablePackages[pkgName] };
|
||||
}
|
||||
updateState(newChosenPackages);
|
||||
};
|
||||
|
||||
const moveAllToChosen = () => {
|
||||
const newPackagesChosen = packagesAvailable.map((pack) => {
|
||||
return {
|
||||
...pack,
|
||||
selected: false,
|
||||
isHidden: isHidden(filterChosen, pack),
|
||||
};
|
||||
});
|
||||
|
||||
const updatedPackagesAvailable = [];
|
||||
const updatedPackagesChosen = [...newPackagesChosen, ...packagesChosen];
|
||||
|
||||
updateState(updatedPackagesAvailable, updatedPackagesChosen);
|
||||
const newChosenPackages = { ...chosenPackages, ...availablePackages };
|
||||
updateState(newChosenPackages);
|
||||
};
|
||||
|
||||
const moveAllToAvailable = () => {
|
||||
const updatedPackagesChosen = packagesChosen.filter(
|
||||
(pack) => pack.isHidden
|
||||
);
|
||||
|
||||
const newPackagesAvailable =
|
||||
filterAvailable === undefined
|
||||
? []
|
||||
: packagesChosen
|
||||
.filter(
|
||||
(pack) => !pack.isHidden && pack.name.includes(filterAvailable)
|
||||
)
|
||||
.map((pack) => {
|
||||
return { ...pack, selected: false };
|
||||
});
|
||||
|
||||
const updatedPackagesAvailable = [
|
||||
...newPackagesAvailable,
|
||||
...packagesAvailable,
|
||||
];
|
||||
|
||||
updateState(updatedPackagesAvailable, updatedPackagesChosen);
|
||||
};
|
||||
|
||||
const onOptionSelect = (event, index, isChosen) => {
|
||||
if (isChosen) {
|
||||
const newChosen = [...packagesChosen];
|
||||
newChosen[index].selected = !packagesChosen[index].selected;
|
||||
setPackagesChosenSorted(newChosen);
|
||||
} else {
|
||||
const newAvailable = [...packagesAvailable];
|
||||
newAvailable[index].selected = !packagesAvailable[index].selected;
|
||||
setPackagesAvailableSorted(newAvailable);
|
||||
const removeSelectedFromChosen = () => {
|
||||
const newChosenPackages = {};
|
||||
for (const pkgName in chosenPackages) {
|
||||
if (!selectedChosenPackages.has(pkgName)) {
|
||||
newChosenPackages[pkgName] = { ...chosenPackages[pkgName] };
|
||||
}
|
||||
}
|
||||
updateState(newChosenPackages);
|
||||
};
|
||||
|
||||
const firstInputElement = useRef(null);
|
||||
const removeAllFromChosen = () => {
|
||||
const newChosenPackages = {};
|
||||
updateState(newChosenPackages);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
firstInputElement.current?.focus();
|
||||
}, []);
|
||||
const handleSelectAvailable = (event, pkgName) => {
|
||||
const newSelected = new Set(selectedAvailablePackages);
|
||||
newSelected.has(pkgName)
|
||||
? newSelected.delete(pkgName)
|
||||
: newSelected.add(pkgName);
|
||||
setSelectedAvailablePackages(newSelected);
|
||||
};
|
||||
|
||||
const handleSelectChosen = (event, pkgName) => {
|
||||
const newSelected = new Set(selectedChosenPackages);
|
||||
newSelected.has(pkgName)
|
||||
? newSelected.delete(pkgName)
|
||||
: newSelected.add(pkgName);
|
||||
setSelectedChosenPackages(newSelected);
|
||||
};
|
||||
|
||||
const handleClearAvailableSearch = () => {
|
||||
setPackagesSearchName(undefined);
|
||||
setFilterAvailable(undefined);
|
||||
setPackagesAvailable([]);
|
||||
setPackagesAvailableFound(true);
|
||||
setPackagesSearchName('');
|
||||
setAvailablePackages(undefined);
|
||||
};
|
||||
|
||||
const handleClearChosenSearch = () => {
|
||||
setFilterChosen(undefined);
|
||||
setPackagesChosenSorted(
|
||||
packagesChosen.map((pack) => {
|
||||
return { ...pack, isHidden: false };
|
||||
})
|
||||
);
|
||||
setPackagesChosenFound(true);
|
||||
setFilterChosen('');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -339,48 +261,48 @@ const Packages = ({ defaultArch, ...props }) => {
|
|||
onBlur={() => setFocus('')}
|
||||
onChange={(val) => setPackagesSearchName(val)}
|
||||
submitSearchButtonLabel="Search button for available packages"
|
||||
onSearch={handlePackagesAvailableSearch}
|
||||
onSearch={handleAvailablePackagesSearch}
|
||||
resetButtonLabel="Clear available packages search"
|
||||
onClear={handleClearAvailableSearch}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<DualListSelectorList data-testid="available-pkgs-list">
|
||||
{!packagesAvailable.length ? (
|
||||
{availablePackages === undefined ? (
|
||||
<p className="pf-u-text-align-center pf-u-mt-md">
|
||||
{!packagesAvailableFound ? (
|
||||
'No packages found'
|
||||
) : (
|
||||
<>
|
||||
Search above to add additional
|
||||
<br />
|
||||
packages to your image
|
||||
</>
|
||||
)}
|
||||
Search above to add additional
|
||||
<br />
|
||||
packages to your image
|
||||
</p>
|
||||
) : availablePackagesDisplayList.length === 0 ? (
|
||||
<p className="pf-u-text-align-center pf-u-mt-md">
|
||||
No packages found
|
||||
</p>
|
||||
) : (
|
||||
packagesAvailable.map((pack, index) => {
|
||||
return !pack.isHidden ? (
|
||||
availablePackagesDisplayList.map((pkg) => {
|
||||
return (
|
||||
<DualListSelectorListItem
|
||||
key={index}
|
||||
isSelected={pack.selected}
|
||||
onOptionSelect={(e) => onOptionSelect(e, index, false)}
|
||||
data-testid={`available-pkgs-${pkg.name}`}
|
||||
key={pkg.name}
|
||||
isDisabled={chosenPackages[pkg.name] ? true : false}
|
||||
isSelected={selectedAvailablePackages.has(pkg.name)}
|
||||
onOptionSelect={(e) => handleSelectAvailable(e, pkg.name)}
|
||||
>
|
||||
<TextContent key={`${pack.name}-${index}`}>
|
||||
<TextContent key={`${pkg.name}`}>
|
||||
<span className="pf-c-dual-list-selector__item-text">
|
||||
{pack.name}
|
||||
{pkg.name}
|
||||
</span>
|
||||
<small>{pack.summary}</small>
|
||||
<small>{pkg.summary}</small>
|
||||
</TextContent>
|
||||
</DualListSelectorListItem>
|
||||
) : null;
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DualListSelectorList>
|
||||
</DualListSelectorPane>
|
||||
<DualListSelectorControlsWrapper aria-label="Selector controls">
|
||||
<DualListSelectorControl
|
||||
isDisabled={!packagesAvailable.some((option) => option.selected)}
|
||||
isDisabled={selectedAvailablePackages.size === 0}
|
||||
onClick={() => moveSelectedToChosen()}
|
||||
aria-label="Add selected"
|
||||
tooltipContent="Add selected"
|
||||
|
|
@ -388,7 +310,7 @@ const Packages = ({ defaultArch, ...props }) => {
|
|||
<AngleRightIcon />
|
||||
</DualListSelectorControl>
|
||||
<DualListSelectorControl
|
||||
isDisabled={!packagesAvailable.length}
|
||||
isDisabled={availablePackagesDisplayList.length === 0}
|
||||
onClick={() => moveAllToChosen()}
|
||||
aria-label="Add all"
|
||||
tooltipContent="Add all"
|
||||
|
|
@ -396,19 +318,16 @@ const Packages = ({ defaultArch, ...props }) => {
|
|||
<AngleDoubleRightIcon />
|
||||
</DualListSelectorControl>
|
||||
<DualListSelectorControl
|
||||
isDisabled={!packagesChosen.length || !packagesChosenFound}
|
||||
onClick={() => moveAllToAvailable()}
|
||||
isDisabled={Object.values(chosenPackages).length === 0}
|
||||
onClick={() => removeAllFromChosen()}
|
||||
aria-label="Remove all"
|
||||
tooltipContent="Remove all"
|
||||
>
|
||||
<AngleDoubleLeftIcon />
|
||||
</DualListSelectorControl>
|
||||
<DualListSelectorControl
|
||||
onClick={() => moveSelectedToAvailable()}
|
||||
isDisabled={
|
||||
!packagesChosen.some((option) => option.selected) ||
|
||||
!packagesChosenFound
|
||||
}
|
||||
onClick={() => removeSelectedFromChosen()}
|
||||
isDisabled={selectedChosenPackages.size === 0}
|
||||
aria-label="Remove selected"
|
||||
tooltipContent="Remove selected"
|
||||
>
|
||||
|
|
@ -424,7 +343,7 @@ const Packages = ({ defaultArch, ...props }) => {
|
|||
value={filterChosen}
|
||||
onFocus={() => setFocus('chosen')}
|
||||
onBlur={() => setFocus('')}
|
||||
onChange={(val) => handlePackagesChosenSearch(val)}
|
||||
onChange={(val) => setFilterChosen(val)}
|
||||
resetButtonLabel="Clear chosen packages search"
|
||||
onClear={handleClearChosenSearch}
|
||||
/>
|
||||
|
|
@ -432,30 +351,31 @@ const Packages = ({ defaultArch, ...props }) => {
|
|||
isChosen
|
||||
>
|
||||
<DualListSelectorList data-testid="chosen-pkgs-list">
|
||||
{!packagesChosen.length ? (
|
||||
{Object.values(chosenPackages).length === 0 ? (
|
||||
<p className="pf-u-text-align-center pf-u-mt-md">
|
||||
No packages added
|
||||
</p>
|
||||
) : !packagesChosenFound ? (
|
||||
) : chosenPackagesDisplayList.length === 0 ? (
|
||||
<p className="pf-u-text-align-center pf-u-mt-md">
|
||||
No packages found
|
||||
</p>
|
||||
) : (
|
||||
packagesChosen.map((pack, index) => {
|
||||
return !pack.isHidden ? (
|
||||
chosenPackagesDisplayList.map((pkg) => {
|
||||
return (
|
||||
<DualListSelectorListItem
|
||||
key={index}
|
||||
isSelected={pack.selected}
|
||||
onOptionSelect={(e) => onOptionSelect(e, index, true)}
|
||||
data-testid={`selected-pkgs-${pkg.name}`}
|
||||
key={pkg.name}
|
||||
isSelected={selectedChosenPackages.has(pkg.name)}
|
||||
onOptionSelect={(e) => handleSelectChosen(e, pkg.name)}
|
||||
>
|
||||
<TextContent key={`${pack.name}-${index}`}>
|
||||
<TextContent key={`${pkg.name}`}>
|
||||
<span className="pf-c-dual-list-selector__item-text">
|
||||
{pack.name}
|
||||
{pkg.name}
|
||||
</span>
|
||||
<small>{pack.summary}</small>
|
||||
<small>{pkg.summary}</small>
|
||||
</TextContent>
|
||||
</DualListSelectorListItem>
|
||||
) : null;
|
||||
);
|
||||
})
|
||||
)}
|
||||
</DualListSelectorList>
|
||||
|
|
@ -464,8 +384,10 @@ const Packages = ({ defaultArch, ...props }) => {
|
|||
);
|
||||
};
|
||||
|
||||
Packages.propTypes = {
|
||||
RedHatPackages.propTypes = {
|
||||
defaultArch: PropTypes.string,
|
||||
};
|
||||
|
||||
export default Packages;
|
||||
Packages.propTypes = {
|
||||
getAllPackages: PropTypes.func,
|
||||
};
|
||||
|
|
|
|||
400
src/Components/CreateImageWizard/formComponents/Repositories.js
Normal file
400
src/Components/CreateImageWizard/formComponents/Repositories.js
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
useFieldApi,
|
||||
useFormApi,
|
||||
} from '@data-driven-forms/react-form-renderer';
|
||||
import {
|
||||
Button,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
DropdownToggle,
|
||||
DropdownToggleCheckbox,
|
||||
EmptyState,
|
||||
EmptyStateBody,
|
||||
EmptyStateIcon,
|
||||
EmptyStateVariant,
|
||||
Pagination,
|
||||
SearchInput,
|
||||
Title,
|
||||
Toolbar,
|
||||
ToolbarContent,
|
||||
ToolbarItem,
|
||||
} from '@patternfly/react-core';
|
||||
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
|
||||
import { RepositoryIcon } from '@patternfly/react-icons';
|
||||
import {
|
||||
TableComposable,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from '@patternfly/react-table';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { selectValidRepositories } from '../../../store/repositoriesSlice';
|
||||
|
||||
const BulkSelect = ({
|
||||
selected,
|
||||
count,
|
||||
filteredCount,
|
||||
perPage,
|
||||
handleSelectAll,
|
||||
handleSelectPage,
|
||||
handleDeselectAll,
|
||||
}) => {
|
||||
const [dropdownIsOpen, setDropdownIsOpen] = useState(false);
|
||||
|
||||
const numSelected = selected.length;
|
||||
const allSelected = count !== 0 ? numSelected === count : undefined;
|
||||
const anySelected = numSelected > 0;
|
||||
const someChecked = anySelected ? null : false;
|
||||
const isChecked = allSelected ? true : someChecked;
|
||||
|
||||
const items = [
|
||||
<DropdownItem
|
||||
key="none"
|
||||
onClick={handleDeselectAll}
|
||||
>{`Select none (0 items)`}</DropdownItem>,
|
||||
<DropdownItem key="page" onClick={handleSelectPage}>{`Select page (${
|
||||
perPage > filteredCount ? filteredCount : perPage
|
||||
} items)`}</DropdownItem>,
|
||||
<DropdownItem
|
||||
key="all"
|
||||
onClick={handleSelectAll}
|
||||
>{`Select all (${count} items)`}</DropdownItem>,
|
||||
];
|
||||
|
||||
const handleDropdownSelect = () => {};
|
||||
|
||||
const toggleDropdown = () => setDropdownIsOpen(!dropdownIsOpen);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
onSelect={handleDropdownSelect}
|
||||
toggle={
|
||||
<DropdownToggle
|
||||
id="stacked-example-toggle"
|
||||
splitButtonItems={[
|
||||
<DropdownToggleCheckbox
|
||||
id="example-checkbox-1"
|
||||
key="split-checkbox"
|
||||
aria-label="Select all"
|
||||
isChecked={isChecked}
|
||||
onClick={() => {
|
||||
anySelected ? handleDeselectAll() : handleSelectAll();
|
||||
}}
|
||||
/>,
|
||||
]}
|
||||
onToggle={toggleDropdown}
|
||||
>
|
||||
{numSelected !== 0 ? `${numSelected} selected` : null}
|
||||
</DropdownToggle>
|
||||
}
|
||||
isOpen={dropdownIsOpen}
|
||||
dropdownItems={items}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// Utility function to convert from Content Sources to Image Builder API schema
|
||||
const convertSchemaToImageBuilder = (repo) => {
|
||||
const imageBuilderRepo = {
|
||||
baseurl: repo.url,
|
||||
rhsm: false,
|
||||
};
|
||||
if (repo.gpg_key) {
|
||||
imageBuilderRepo.gpgkey = repo.gpg_key;
|
||||
imageBuilderRepo.check_gpg = true;
|
||||
}
|
||||
|
||||
return imageBuilderRepo;
|
||||
};
|
||||
|
||||
// Utility function to convert from Image Builder to Content Sources API schema
|
||||
const convertSchemaToContentSources = (repo) => {
|
||||
const contentSourcesRepo = {
|
||||
url: repo.baseurl,
|
||||
rhsm: false,
|
||||
};
|
||||
if (repo.gpgkey) {
|
||||
contentSourcesRepo.gpg_key = repo.gpgkey;
|
||||
}
|
||||
|
||||
return contentSourcesRepo;
|
||||
};
|
||||
|
||||
const Repositories = (props) => {
|
||||
const initializeRepositories = () => {
|
||||
// Repositories obtained from Content Sources API are in Redux store
|
||||
const contentSourcesRepos = useSelector((state) =>
|
||||
selectValidRepositories(state)
|
||||
);
|
||||
|
||||
// Repositories in the form state can be present when 'Recreate image' is used
|
||||
// to open the wizard that are not necessarily in content sources.
|
||||
const formStateReposList = getState()?.values?.['payload-repositories'];
|
||||
|
||||
const mergeRepositories = (contentSourcesRepos, formStateReposList) => {
|
||||
const formStateRepos = {};
|
||||
|
||||
for (const repo of formStateReposList) {
|
||||
formStateRepos[repo.baseurl] = convertSchemaToContentSources(repo);
|
||||
formStateRepos[repo.baseurl].name = '';
|
||||
}
|
||||
|
||||
// In case of duplicate repo urls, the repo from Content Sources overwrites the
|
||||
// repo from the form state.
|
||||
const mergedRepos = { ...formStateRepos, ...contentSourcesRepos };
|
||||
|
||||
return mergedRepos;
|
||||
};
|
||||
|
||||
const repositories = formStateReposList
|
||||
? mergeRepositories(contentSourcesRepos, formStateReposList)
|
||||
: contentSourcesRepos;
|
||||
|
||||
return repositories;
|
||||
};
|
||||
|
||||
const { getState } = useFormApi();
|
||||
const { input } = useFieldApi(props);
|
||||
const [repositories] = useState(initializeRepositories());
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
const [perPage, setPerPage] = useState(10);
|
||||
const [page, setPage] = useState(1);
|
||||
const [selected, setSelected] = useState(
|
||||
getState()?.values?.['third-party-repositories']
|
||||
? getState().values['third-party-repositories'].map(
|
||||
(repo) => repo.baseurl
|
||||
)
|
||||
: []
|
||||
);
|
||||
|
||||
const isRepoSelected = (repoURL) => selected.includes(repoURL);
|
||||
|
||||
const handlePerPageSelect = (event, newPerPage, newPage) => {
|
||||
setPerPage(newPerPage);
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
const handleSetPage = (event, newPage) => {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
// filter displayed selected packages
|
||||
const handleFilterRepositories = (value) => {
|
||||
setPage(1);
|
||||
setFilterValue(value);
|
||||
};
|
||||
|
||||
const filteredRepositoryURLs = useMemo(() => {
|
||||
const filteredRepoURLs = Object.values(repositories)
|
||||
.filter((repo) =>
|
||||
repo.name.toLowerCase().includes(filterValue.toLowerCase())
|
||||
)
|
||||
.map((repo) => repo.url);
|
||||
|
||||
return filteredRepoURLs;
|
||||
}, [filterValue]);
|
||||
|
||||
const handleClearFilter = () => {
|
||||
setFilterValue('');
|
||||
};
|
||||
|
||||
const updateFormState = (selectedRepoURLs) => {
|
||||
// repositories is stored as an object with repoURLs as keys
|
||||
const selectedRepos = [];
|
||||
for (const repoURL of selectedRepoURLs) {
|
||||
selectedRepos.push(repositories[repoURL]);
|
||||
}
|
||||
|
||||
const payloadRepositories = selectedRepos.map((repo) =>
|
||||
convertSchemaToImageBuilder(repo)
|
||||
);
|
||||
|
||||
input.onChange(payloadRepositories);
|
||||
};
|
||||
|
||||
const updateSelected = (selectedRepos) => {
|
||||
setSelected(selectedRepos);
|
||||
updateFormState(selectedRepos);
|
||||
};
|
||||
|
||||
const handleSelect = (repoURL, rowIndex, isSelecting) => {
|
||||
if (isSelecting === true) {
|
||||
updateSelected([...selected, repoURL]);
|
||||
} else if (isSelecting === false) {
|
||||
updateSelected(
|
||||
selected.filter((selectedRepoId) => selectedRepoId !== repoURL)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
updateSelected(Object.keys(repositories));
|
||||
};
|
||||
|
||||
const computeStart = () => perPage * (page - 1);
|
||||
const computeEnd = () => perPage * page;
|
||||
|
||||
const handleSelectPage = () => {
|
||||
const pageRepos = filteredRepositoryURLs.slice(
|
||||
computeStart(),
|
||||
computeEnd()
|
||||
);
|
||||
|
||||
// Filter to avoid adding duplicates
|
||||
const newSelected = [
|
||||
...pageRepos.filter((repoId) => !selected.includes(repoId)),
|
||||
];
|
||||
|
||||
updateSelected([...selected, ...newSelected]);
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
updateSelected([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.values(repositories).length === 0 ? (
|
||||
<EmptyState variant={EmptyStateVariant.large} data-testid="empty-state">
|
||||
<EmptyStateIcon icon={RepositoryIcon} />
|
||||
<Title headingLevel="h4" size="lg">
|
||||
No Third Party Repositories
|
||||
</Title>
|
||||
<EmptyStateBody>
|
||||
Third party repositories managed via the Red Hat Insights
|
||||
Repositories app will be available here to select and use to search
|
||||
for additional packages.
|
||||
</EmptyStateBody>
|
||||
<Button
|
||||
variant="primary"
|
||||
component="a"
|
||||
href={
|
||||
insights.chrome.isBeta()
|
||||
? '/beta/settings/content'
|
||||
: '/settings/content'
|
||||
}
|
||||
>
|
||||
Repositories
|
||||
</Button>
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<Toolbar>
|
||||
<ToolbarContent>
|
||||
<ToolbarItem variant="bulk-select">
|
||||
<BulkSelect
|
||||
selected={selected}
|
||||
count={Object.values(repositories).length}
|
||||
filteredCount={filteredRepositoryURLs.length}
|
||||
perPage={perPage}
|
||||
handleSelectAll={handleSelectAll}
|
||||
handleSelectPage={handleSelectPage}
|
||||
handleDeselectAll={handleDeselectAll}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem variant="search-filter">
|
||||
<SearchInput
|
||||
aria-label="Search repositories"
|
||||
onChange={handleFilterRepositories}
|
||||
value={filterValue}
|
||||
onClear={handleClearFilter}
|
||||
/>
|
||||
</ToolbarItem>
|
||||
<ToolbarItem variant="pagination">
|
||||
<Pagination
|
||||
itemCount={filteredRepositoryURLs.length}
|
||||
perPage={perPage}
|
||||
page={page}
|
||||
onSetPage={handleSetPage}
|
||||
widgetId="compact-example"
|
||||
onPerPageSelect={handlePerPageSelect}
|
||||
isCompact
|
||||
/>
|
||||
</ToolbarItem>
|
||||
</ToolbarContent>
|
||||
</Toolbar>
|
||||
<TableComposable variant="compact" data-testid="repositories-table">
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th />
|
||||
<Th width={50}>Name</Th>
|
||||
<Th>Architecture</Th>
|
||||
<Th>Versions</Th>
|
||||
<Th>Packages</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{filteredRepositoryURLs
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
if (repositories[a].name < repositories[b].name) {
|
||||
return -1;
|
||||
} else if (repositories[b].name < repositories[a].name) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
.slice(computeStart(), computeEnd())
|
||||
.map((repoURL, rowIndex) => {
|
||||
const repo = repositories[repoURL];
|
||||
return (
|
||||
<Tr key={repo.url}>
|
||||
<Td
|
||||
select={{
|
||||
isSelected: isRepoSelected(repo.url),
|
||||
rowIndex: rowIndex,
|
||||
onSelect: (event, isSelecting) =>
|
||||
handleSelect(repo.url, rowIndex, isSelecting),
|
||||
}}
|
||||
/>
|
||||
<Td dataLabel={'Name'}>
|
||||
{repo.name}
|
||||
<br />
|
||||
<Button
|
||||
component="a"
|
||||
target="_blank"
|
||||
variant="link"
|
||||
icon={<ExternalLinkAltIcon />}
|
||||
iconPosition="right"
|
||||
isInline
|
||||
href={repo.url}
|
||||
>
|
||||
{repo.url}
|
||||
</Button>
|
||||
</Td>
|
||||
<Td dataLabel={'Architecture'}>
|
||||
{repo.distribution_arch}
|
||||
</Td>
|
||||
<Td dataLabel={'Version'}>
|
||||
{repo.distribution_versions}
|
||||
</Td>
|
||||
<Td dataLabel={'Packages'}>{repo.package_count}</Td>
|
||||
</Tr>
|
||||
);
|
||||
})}
|
||||
</Tbody>
|
||||
</TableComposable>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
BulkSelect.propTypes = {
|
||||
selected: PropTypes.array,
|
||||
count: PropTypes.number,
|
||||
filteredCount: PropTypes.number,
|
||||
perPage: PropTypes.number,
|
||||
handleSelectAll: PropTypes.func,
|
||||
handleSelectPage: PropTypes.func,
|
||||
handleDeselectAll: PropTypes.func,
|
||||
};
|
||||
|
||||
export default Repositories;
|
||||
|
|
@ -14,7 +14,6 @@ export default {
|
|||
title: 'File system configuration',
|
||||
name: 'File system configuration',
|
||||
buttons: FileSystemConfigButtons,
|
||||
substepOf: 'System configuration',
|
||||
nextStep: 'packages',
|
||||
fields: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@ export { default as awsTarget } from './aws';
|
|||
export { default as googleCloudTarger } from './googleCloud';
|
||||
export { default as msAzureTarget } from './msAzure';
|
||||
export { default as packages } from './packages';
|
||||
export { default as packagesContentSources } from './packagesContentSources';
|
||||
export { default as registration } from './registration';
|
||||
export { default as repositories } from './repositories';
|
||||
export { default as review } from './review';
|
||||
export { default as imageOutput } from './imageOutput';
|
||||
export { default as nextStepMapper } from './imageOutputStepMapper';
|
||||
|
|
|
|||
|
|
@ -10,10 +10,16 @@ import CustomButtons from '../formComponents/CustomButtons';
|
|||
export default {
|
||||
StepTemplate,
|
||||
id: 'wizard-systemconfiguration-packages',
|
||||
title: 'Additional packages',
|
||||
title: 'Additional Red Hat packages',
|
||||
name: 'packages',
|
||||
substepOf: 'System configuration',
|
||||
nextStep: 'image-name',
|
||||
substepOf: 'Content',
|
||||
nextStep: () => {
|
||||
if (!insights.chrome.isProd() && insights.chrome.isBeta()) {
|
||||
return 'repositories';
|
||||
} else {
|
||||
return 'image-name';
|
||||
}
|
||||
},
|
||||
buttons: CustomButtons,
|
||||
fields: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
import React from 'react';
|
||||
|
||||
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
|
||||
import { Text } from '@patternfly/react-core';
|
||||
|
||||
import StepTemplate from './stepTemplate';
|
||||
|
||||
export default {
|
||||
StepTemplate,
|
||||
id: 'wizard-systemconfiguration-content-sources-packages',
|
||||
title: 'Additional 3rd Party Packages',
|
||||
name: 'packages-content-sources',
|
||||
substepOf: 'Content',
|
||||
nextStep: 'image-name',
|
||||
fields: [
|
||||
{
|
||||
component: componentTypes.PLAIN_TEXT,
|
||||
name: 'packages-text-component',
|
||||
label: (
|
||||
<Text>
|
||||
Images built with Image Builder include all required packages.
|
||||
<br />
|
||||
You can add additional packages to your image by searching
|
||||
"Available packages" and adding them to the "Chosen
|
||||
packages" list.
|
||||
<br />
|
||||
The available packages will return results from all repositories
|
||||
chosen on the previous page.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
component: 'package-selector-content-sources',
|
||||
name: 'selected-packages-content-sources',
|
||||
label: 'Available options',
|
||||
},
|
||||
],
|
||||
};
|
||||
36
src/Components/CreateImageWizard/steps/repositories.js
Normal file
36
src/Components/CreateImageWizard/steps/repositories.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React from 'react';
|
||||
|
||||
import componentTypes from '@data-driven-forms/react-form-renderer/component-types';
|
||||
import { Text } from '@patternfly/react-core';
|
||||
|
||||
import nextStepMapper from './repositoriesStepMapper';
|
||||
import StepTemplate from './stepTemplate';
|
||||
|
||||
export default {
|
||||
StepTemplate,
|
||||
id: 'wizard-repositories',
|
||||
title: '3rd party repositories',
|
||||
name: 'repositories',
|
||||
substepOf: 'Content',
|
||||
nextStep: ({ values }) => nextStepMapper(values),
|
||||
fields: [
|
||||
{
|
||||
component: componentTypes.PLAIN_TEXT,
|
||||
name: 'packages-text-component',
|
||||
label: (
|
||||
<Text>
|
||||
Select third party repositories from which to search and add packages
|
||||
to this image.
|
||||
<br />
|
||||
Third party repositories can be managed using the Repositories app on
|
||||
Red Hat Insights.
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
component: 'repositories-table',
|
||||
name: 'third-party-repositories',
|
||||
label: 'Third party repositories',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
export default ({
|
||||
'third-party-repositories': thirdPartyRepositories,
|
||||
} = {}) => {
|
||||
if (thirdPartyRepositories?.length > 0) {
|
||||
return 'packages-content-sources';
|
||||
}
|
||||
|
||||
return 'image-name';
|
||||
};
|
||||
13
src/api.js
13
src/api.js
|
|
@ -1,7 +1,6 @@
|
|||
import axios from 'axios';
|
||||
|
||||
import { CONTENT_SOURCES, IMAGE_BUILDER_API, RHSM_API } from './constants';
|
||||
import { repos } from './repos';
|
||||
|
||||
const postHeaders = { headers: { 'Content-Type': 'application/json' } };
|
||||
|
||||
|
|
@ -43,10 +42,17 @@ async function getPackages(distribution, architecture, search, limit) {
|
|||
return request.data;
|
||||
}
|
||||
|
||||
async function getPackagesContentSources(distribution, search) {
|
||||
async function getRepositories(limit) {
|
||||
const params = new URLSearchParams();
|
||||
limit && params.append('limit', limit);
|
||||
const path = '/repositories/' + params.toString();
|
||||
const request = await axios.get(CONTENT_SOURCES.concat(path));
|
||||
return request.data;
|
||||
}
|
||||
|
||||
async function getPackagesContentSources(repoUrls, search) {
|
||||
// content-sources expects an array of urls but we store the whole repo object
|
||||
// so map the urls into an array to send to the content-sources api
|
||||
const repoUrls = repos[distribution].map((repo) => repo.url);
|
||||
const body = {
|
||||
urls: repoUrls,
|
||||
search,
|
||||
|
|
@ -119,6 +125,7 @@ export default {
|
|||
getComposeStatus,
|
||||
getPackages,
|
||||
getPackagesContentSources,
|
||||
getRepositories,
|
||||
getVersion,
|
||||
getActivationKeys,
|
||||
getActivationKey,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { RHEL_8, RHEL_9 } from './constants';
|
||||
|
||||
export const getDistroRepoUrls = (distro) =>
|
||||
repos[distro].map((repo) => repo.url);
|
||||
|
||||
export const repos = {
|
||||
[RHEL_8]: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,11 +4,13 @@ import promiseMiddleware from 'redux-promise-middleware';
|
|||
|
||||
import clonesSlice from './clonesSlice';
|
||||
import composesSlice from './composesSlice';
|
||||
import repositoriesSlice from './repositoriesSlice';
|
||||
|
||||
export const reducer = {
|
||||
clones: clonesSlice,
|
||||
composes: composesSlice,
|
||||
notifications: notificationsReducer,
|
||||
repositories: repositoriesSlice,
|
||||
};
|
||||
|
||||
export const middleware = (getDefaultMiddleware) =>
|
||||
|
|
|
|||
65
src/store/repositoriesSlice.js
Normal file
65
src/store/repositoriesSlice.js
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { createSlice } from '@reduxjs/toolkit';
|
||||
|
||||
import api from '../api';
|
||||
|
||||
const initialState = {
|
||||
count: 0,
|
||||
allIds: [],
|
||||
byId: {},
|
||||
error: null,
|
||||
};
|
||||
|
||||
export const fetchRepositories = () => async (dispatch) => {
|
||||
let { data, meta } = await api.getRepositories();
|
||||
if (data.length < meta.count) {
|
||||
({ data } = await api.getRepositories(meta.count));
|
||||
}
|
||||
dispatch(repositoriesAdded({ repositories: data }));
|
||||
dispatch(repositoriesUpdatedCount({ count: data.length }));
|
||||
};
|
||||
|
||||
export const repositoriesSlice = createSlice({
|
||||
name: 'repositories',
|
||||
initialState,
|
||||
reducers: {
|
||||
repositoriesAdded: (state, action) => {
|
||||
action.payload.repositories.map((repo) => {
|
||||
// The repo url is used as the id
|
||||
if (!state.allIds.includes(repo.url)) {
|
||||
state.allIds.push(repo.url);
|
||||
}
|
||||
state.byId[repo.url] = repo;
|
||||
});
|
||||
},
|
||||
repositoriesUpdatedCount: (state, action) => {
|
||||
state.count = action.payload.count;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const selectRepositoryById = (state, repoId) =>
|
||||
state.repositories.byId[repoId];
|
||||
|
||||
export const selectValidRepositoryIds = (state) => {
|
||||
const validRepositoryIds = [];
|
||||
for (const repoId of state.repositories.allIds) {
|
||||
if (state.repositories.byId[repoId].status === 'Valid') {
|
||||
validRepositoryIds.push(repoId);
|
||||
}
|
||||
}
|
||||
return validRepositoryIds;
|
||||
};
|
||||
|
||||
export const selectValidRepositories = (state) => {
|
||||
const validRepositories = {};
|
||||
for (const repoId of state.repositories.allIds) {
|
||||
if (state.repositories.byId[repoId].status === 'Valid') {
|
||||
validRepositories[repoId] = state.repositories.byId[repoId];
|
||||
}
|
||||
}
|
||||
return validRepositories;
|
||||
};
|
||||
|
||||
export const { repositoriesAdded, repositoriesUpdatedCount } =
|
||||
repositoriesSlice.actions;
|
||||
export default repositoriesSlice.reducer;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -188,7 +188,10 @@ describe('Create Image Wizard', () => {
|
|||
screen.getByRole('heading', { name: /Create image/ });
|
||||
|
||||
screen.getByRole('button', { name: 'Image output' });
|
||||
screen.getByRole('button', { name: 'Additional packages' });
|
||||
screen.getByRole('button', { name: 'Registration' });
|
||||
screen.getByRole('button', { name: 'File system configuration' });
|
||||
screen.getByRole('button', { name: 'Content' });
|
||||
screen.getByRole('button', { name: 'Additional Red Hat packages' });
|
||||
screen.getByRole('button', { name: 'Name image' });
|
||||
screen.getByRole('button', { name: 'Review' });
|
||||
});
|
||||
|
|
@ -850,14 +853,10 @@ describe('Step Packages', () => {
|
|||
await searchForAvailablePackages(searchbox, 'test');
|
||||
expect(getPackages).toHaveBeenCalledTimes(1);
|
||||
|
||||
screen
|
||||
.getByRole('option', { name: /testPkg test package summary/ })
|
||||
.click();
|
||||
screen.getByTestId('available-pkgs-testPkg').click();
|
||||
screen.getByRole('button', { name: /Add selected/ }).click();
|
||||
|
||||
screen
|
||||
.getByRole('option', { name: /testPkg test package summary/ })
|
||||
.click();
|
||||
screen.getByTestId('selected-pkgs-testPkg').click();
|
||||
screen.getByRole('button', { name: /Remove selected/ }).click();
|
||||
|
||||
const availablePackagesList = screen.getByTestId('available-pkgs-list');
|
||||
|
|
@ -913,9 +912,7 @@ describe('Step Packages', () => {
|
|||
screen.getByRole('button', { name: /Add all/ }).click();
|
||||
|
||||
// remove a single package
|
||||
screen
|
||||
.getByRole('option', { name: /lib-test lib-test package summary/ })
|
||||
.click();
|
||||
screen.getByTestId('selected-pkgs-lib-test').click();
|
||||
screen.getByRole('button', { name: /Remove selected/ }).click();
|
||||
|
||||
// skip name page
|
||||
|
|
@ -1007,58 +1004,6 @@ describe('Step Packages', () => {
|
|||
await searchForChosenPackages(searchboxChosen, '');
|
||||
});
|
||||
|
||||
test('should filter chosen packages from available list', async () => {
|
||||
await setUp();
|
||||
|
||||
const searchboxAvailable = screen.getAllByRole('textbox')[0];
|
||||
const availablePackagesList = screen.getByTestId('available-pkgs-list');
|
||||
const chosenPackagesList = screen.getByTestId('chosen-pkgs-list');
|
||||
|
||||
const getPackages = jest
|
||||
.spyOn(api, 'getPackages')
|
||||
.mockImplementation(() => Promise.resolve(mockPkgResult));
|
||||
|
||||
searchboxAvailable.click();
|
||||
await searchForAvailablePackages(searchboxAvailable, 'test');
|
||||
expect(getPackages).toHaveBeenCalledTimes(1);
|
||||
|
||||
let availablePackagesItems = within(availablePackagesList).getAllByRole(
|
||||
'option'
|
||||
);
|
||||
expect(availablePackagesItems).toHaveLength(3);
|
||||
|
||||
screen
|
||||
.getByRole('option', { name: /testPkg test package summary/ })
|
||||
.click();
|
||||
screen.getByRole('button', { name: /Add selected/ }).click();
|
||||
|
||||
availablePackagesItems = within(availablePackagesList).getAllByRole(
|
||||
'option'
|
||||
);
|
||||
expect(availablePackagesItems).toHaveLength(2);
|
||||
|
||||
let chosenPackagesItems = within(chosenPackagesList).getAllByRole('option');
|
||||
// Knowing if it is in document isn't enough. We want a specific length of 1 so ignore rule.
|
||||
// eslint-disable-next-line jest-dom/prefer-in-document
|
||||
expect(chosenPackagesItems).toHaveLength(1);
|
||||
|
||||
searchboxAvailable.click();
|
||||
await searchForAvailablePackages(searchboxAvailable, 'test');
|
||||
expect(getPackages).toHaveBeenCalledTimes(2);
|
||||
|
||||
availablePackagesItems = within(availablePackagesList).getAllByRole(
|
||||
'option'
|
||||
);
|
||||
chosenPackagesItems = within(chosenPackagesList).getAllByRole('option');
|
||||
expect(availablePackagesItems).toHaveLength(2);
|
||||
// Knowing if it is in document isn't enough. We want a specific length of 1 so ignore rule.
|
||||
// eslint-disable-next-line jest-dom/prefer-in-document
|
||||
expect(chosenPackagesItems).toHaveLength(1);
|
||||
within(chosenPackagesList).getByRole('option', {
|
||||
name: /testPkg test package summary/,
|
||||
});
|
||||
});
|
||||
|
||||
test('should get all packages, regardless of api default limit', async () => {
|
||||
await setUp();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue