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
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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue