debian-image-builder-frontend/src/Components/CreateImageWizardV2/steps/Repositories/Repositories.tsx
lucasgarfield e923cccd41 V2Wizard/Repositories: Add payload_repositories to request and test it
Previously the V2 Wizard's request mapper was only adding the
custom_repositories field. We also need to add a nearly duplicate
payload_repositories field due to how the image-builder API works...
which is admittedly not intuitive.

Tests are also added to ensure that requests are generated correctly
when using the custom repositories feature.
2024-03-01 10:02:52 +01:00

593 lines
18 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import {
Alert,
Button,
EmptyState,
EmptyStateBody,
EmptyStateIcon,
EmptyStateVariant,
Pagination,
Panel,
PanelMain,
SearchInput,
Spinner,
Toolbar,
ToolbarContent,
ToolbarItem,
EmptyStateHeader,
EmptyStateFooter,
ToggleGroup,
ToggleGroupItem,
} from '@patternfly/react-core';
import {
Dropdown,
DropdownItem,
DropdownToggle,
DropdownToggleCheckbox,
} from '@patternfly/react-core/deprecated';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { RepositoryIcon } from '@patternfly/react-icons';
import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
import RepositoriesStatus from './RepositoriesStatus';
import RepositoryUnavailable from './RepositoryUnavailable';
import {
ApiRepositoryResponseRead,
useListRepositoriesQuery,
} from '../../../../store/contentSourcesApi';
import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
import {
CustomRepository,
Repository,
} from '../../../../store/imageBuilderApi';
import {
changeCustomRepositories,
changePayloadRepositories,
selectArchitecture,
selectCustomRepositories,
selectDistribution,
} from '../../../../store/wizardSlice';
import { releaseToVersion } from '../../../../Utilities/releaseToVersion';
import { useGetEnvironment } from '../../../../Utilities/useGetEnvironment';
type BulkSelectProps = {
selected: (string | undefined)[];
count: number | undefined;
filteredCount: number | undefined;
perPage: number;
handleSelectAll: Function;
handleSelectPage: Function;
handleDeselectAll: Function;
isDisabled: boolean;
};
const BulkSelect = ({
selected,
count,
filteredCount,
perPage,
handleSelectAll,
handleSelectPage,
handleDeselectAll,
isDisabled,
}: BulkSelectProps) => {
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"
isDisabled={isDisabled}
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 custom repo API schema
const convertSchemaToIBCustomRepo = (repo: ApiRepositoryResponseRead) => {
const imageBuilderRepo: CustomRepository = {
id: repo.uuid!,
name: repo.name,
baseurl: [repo.url!],
check_gpg: false,
};
if (repo.gpg_key) {
imageBuilderRepo.gpgkey = [repo.gpg_key];
imageBuilderRepo.check_gpg = true;
imageBuilderRepo.check_repo_gpg = repo.metadata_verification;
}
return imageBuilderRepo;
};
// Utility function to convert from Content Sources to Image Builder payload repo API schema
const convertSchemaToIBPayloadRepo = (repo: ApiRepositoryResponseRead) => {
const imageBuilderRepo: Repository = {
baseurl: repo.url,
rhsm: false,
check_gpg: false,
};
if (repo.gpg_key) {
imageBuilderRepo.gpgkey = repo.gpg_key;
imageBuilderRepo.check_gpg = true;
imageBuilderRepo.check_repo_gpg = repo.metadata_verification;
}
return imageBuilderRepo;
};
const Repositories = () => {
const dispatch = useAppDispatch();
const arch = useAppSelector((state) => selectArchitecture(state));
const distribution = useAppSelector((state) => selectDistribution(state));
const version = releaseToVersion(distribution);
const repositoriesList = useAppSelector((state) =>
selectCustomRepositories(state)
);
const [filterValue, setFilterValue] = useState('');
const [perPage, setPerPage] = useState(10);
const [page, setPage] = useState(1);
const [toggleSelected, setToggleSelected] = useState('toggle-group-all');
const [selected, setSelected] = useState(
repositoriesList ? repositoriesList.flatMap((repo) => repo.baseurl) : []
);
const firstRequest = useListRepositoriesQuery(
{
availableForArch: arch,
availableForVersion: version,
contentType: 'rpm',
origin: 'external',
limit: 100,
offset: 0,
},
// The cached repos may be incorrect, for now refetch on mount to ensure that
// they are accurate when this step loads. Future PR will implement prefetching
// and this can be removed.
{ refetchOnMountOrArgChange: true }
);
const skip =
firstRequest?.data?.meta?.count === undefined ||
firstRequest?.data?.meta?.count <= 100;
// Fetch *all* repositories if there are more than 100 so that typeahead filter works
const followupRequest = useListRepositoriesQuery(
{
availableForArch: arch,
availableForVersion: version,
contentType: 'rpm',
origin: 'external',
limit: firstRequest?.data?.meta?.count,
offset: 0,
},
{
refetchOnMountOrArgChange: true,
skip: skip,
}
);
const { data, isError, isFetching, isLoading, isSuccess, refetch } =
useMemo(() => {
if (firstRequest?.data?.meta?.count) {
if (firstRequest?.data?.meta?.count > 100) {
return { ...followupRequest };
}
}
return { ...firstRequest };
}, [firstRequest, followupRequest]);
const handleToggleClick = (event: React.MouseEvent) => {
const id = event.currentTarget.id;
setPage(1);
setToggleSelected(id);
};
const isRepoSelected = (repoURL: string | undefined) =>
selected.includes(repoURL);
const handlePerPageSelect = (
_: React.MouseEvent,
newPerPage: number,
newPage: number
) => {
setPerPage(newPerPage);
setPage(newPage);
};
const handleSetPage = (_: React.MouseEvent, newPage: number) => {
setPage(newPage);
};
// filter displayed selected packages
const handleFilterRepositories = (
event: React.FormEvent<HTMLInputElement>,
value: string
) => {
setPage(1);
setFilterValue(value);
};
const filteredRepositoryURLs = useMemo(() => {
if (!data || !data.data) {
return [];
}
const repoUrls = data.data.filter((repo) =>
repo.name?.toLowerCase().includes(filterValue.toLowerCase())
);
if (toggleSelected === 'toggle-group-all') {
return repoUrls.map((repo: ApiRepositoryResponseRead) => repo.url);
} else if (toggleSelected === 'toggle-group-selected') {
return repoUrls
.filter((repo: ApiRepositoryResponseRead) => isRepoSelected(repo.url!))
.map((repo: ApiRepositoryResponseRead) => repo.url);
}
}, [filterValue, data, toggleSelected]);
const handleClearFilter = () => {
setFilterValue('');
};
const updateFormState = (selectedRepoURLs: (string | undefined)[]) => {
// repositories is stored as an object with repoURLs as keys
const selectedRepos = [];
for (const repoURL of selectedRepoURLs) {
selectedRepos.push(data?.data?.find((repo) => repo.url === repoURL));
}
const customRepositories = selectedRepos.map((repo) =>
convertSchemaToIBCustomRepo(repo!)
);
const payloadRepositories = selectedRepos.map((repo) =>
convertSchemaToIBPayloadRepo(repo!)
);
dispatch(changeCustomRepositories(customRepositories));
dispatch(changePayloadRepositories(payloadRepositories));
};
const updateSelected = (selectedRepos: (string | undefined)[]) => {
setSelected(selectedRepos);
updateFormState(selectedRepos);
};
const handleSelect = (
repoURL: string | undefined,
_: number,
isSelecting: boolean
) => {
if (isSelecting === true) {
updateSelected([...selected, repoURL]);
} else if (isSelecting === false) {
updateSelected(
selected.filter((selectedRepoId) => selectedRepoId !== repoURL)
);
}
};
const handleSelectAll = () => {
if (data) {
updateSelected(data.data?.map((repo) => repo.url) || []);
}
};
const computeStart = () => perPage * (page - 1);
const computeEnd = () => perPage * page;
const handleSelectPage = () => {
const pageRepos =
filteredRepositoryURLs &&
filteredRepositoryURLs.slice(computeStart(), computeEnd());
// Filter to avoid adding duplicates
const newSelected = pageRepos && [
...pageRepos.filter((repoId) => !selected.includes(repoId)),
];
updateSelected([...selected, ...newSelected!]);
};
const handleDeselectAll = () => {
updateSelected([]);
};
const getRepoNameByUrl = (url: string) => {
return data!.data?.find((repo) => repo.url === url)?.name;
};
return (
(isError && <Error />) ||
(isLoading && <Loading />) ||
(isSuccess && (
<>
{data.data?.length === 0 ? (
<Empty refetch={refetch} isFetching={isFetching} />
) : (
<>
<Toolbar>
<ToolbarContent>
<ToolbarItem variant="bulk-select">
<BulkSelect
selected={selected}
count={data.data?.length}
filteredCount={filteredRepositoryURLs?.length}
perPage={perPage}
handleSelectAll={handleSelectAll}
handleSelectPage={handleSelectPage}
handleDeselectAll={handleDeselectAll}
isDisabled={isFetching}
/>
</ToolbarItem>
<ToolbarItem variant="search-filter">
<SearchInput
aria-label="Search repositories"
onChange={handleFilterRepositories}
value={filterValue}
onClear={handleClearFilter}
/>
</ToolbarItem>
<ToolbarItem>
<Button
variant="primary"
isInline
onClick={() => refetch()}
isLoading={isFetching}
>
{isFetching ? 'Refreshing' : 'Refresh'}
</Button>
</ToolbarItem>
<ToolbarItem>
<ToggleGroup aria-label="Filter repositories list">
<ToggleGroupItem
text="All"
aria-label="All repositories"
buttonId="toggle-group-all"
isSelected={toggleSelected === 'toggle-group-all'}
onChange={handleToggleClick}
/>
<ToggleGroupItem
text="Selected"
aria-label="Selected repositories"
buttonId="toggle-group-selected"
isSelected={toggleSelected === 'toggle-group-selected'}
onChange={handleToggleClick}
/>
</ToggleGroup>
</ToolbarItem>
<ToolbarItem variant="pagination">
<Pagination
itemCount={
filteredRepositoryURLs && filteredRepositoryURLs.length
}
perPage={perPage}
page={page}
onSetPage={handleSetPage}
widgetId="compact-example"
onPerPageSelect={handlePerPageSelect}
isCompact
/>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<Panel>
<PanelMain>
<RepositoryUnavailable />
<Table variant="compact" data-testid="repositories-table">
<Thead>
<Tr>
<Th />
<Th width={45}>Name</Th>
<Th width={15}>Architecture</Th>
<Th>Version</Th>
<Th width={10}>Packages</Th>
<Th>Status</Th>
</Tr>
</Thead>
<Tbody>
{filteredRepositoryURLs &&
filteredRepositoryURLs
.sort((a, b) => {
if (getRepoNameByUrl(a!)! < getRepoNameByUrl(b!)!) {
return -1;
} else if (
getRepoNameByUrl(b!)! < getRepoNameByUrl(a!)!
) {
return 1;
} else {
return 0;
}
})
.slice(computeStart(), computeEnd())
.map((repoURL, rowIndex) => {
const repo = data?.data?.find(
(repo) => repo.url === repoURL
);
if (!repo) {
return <></>;
}
const repoExists = repo.name ? true : false;
return (
<Tr key={repo.url}>
<Td
select={{
isSelected: isRepoSelected(repo.url),
rowIndex: rowIndex,
onSelect: (event, isSelecting) =>
handleSelect(
repo.url,
rowIndex,
isSelecting
),
isDisabled:
isFetching || repo.status !== 'Valid',
}}
/>
<Td dataLabel={'Name'}>
{repoExists
? repo.name
: 'Repository with the following url is no longer available:'}
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={repo.url}
>
{repo.url}
</Button>
</Td>
<Td dataLabel={'Architecture'}>
{repoExists ? repo.distribution_arch : '-'}
</Td>
<Td dataLabel={'Version'}>
{repoExists ? repo.distribution_versions : '-'}
</Td>
<Td dataLabel={'Packages'}>
{repoExists ? repo.package_count : '-'}
</Td>
<Td dataLabel={'Status'}>
<RepositoriesStatus
repoStatus={
repoExists ? repo.status : 'Unavailable'
}
repoUrl={repo.url}
repoIntrospections={
repo.last_introspection_time
}
repoFailCount={
repo.failed_introspections_count
}
/>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</PanelMain>
</Panel>
</>
)}
</>
))
);
};
const Error = () => {
return (
<Alert title="Repositories unavailable" variant="danger" isPlain isInline>
Repositories cannot be reached, try again later.
</Alert>
);
};
const Loading = () => {
return (
<EmptyState>
<EmptyStateHeader
titleText="Loading"
icon={<EmptyStateIcon icon={Spinner} />}
headingLevel="h4"
/>
</EmptyState>
);
};
type EmptyProps = {
isFetching: boolean;
refetch: Function;
};
const Empty = ({ isFetching, refetch }: EmptyProps) => {
const { isBeta } = useGetEnvironment();
return (
<EmptyState variant={EmptyStateVariant.lg} data-testid="empty-state">
<EmptyStateHeader
titleText="No Custom Repositories"
icon={<EmptyStateIcon icon={RepositoryIcon} />}
headingLevel="h4"
/>
<EmptyStateBody>
Repositories can be added in the &quot;Repositories&quot; area of the
console. Once added, refresh this page to see them.
</EmptyStateBody>
<EmptyStateFooter>
<Button
variant="primary"
component="a"
target="_blank"
href={isBeta() ? '/preview/settings/content' : '/settings/content'}
className="pf-u-mr-sm"
>
Go to repositories
</Button>
<Button
variant="secondary"
isInline
onClick={() => refetch()}
isLoading={isFetching}
>
{isFetching ? 'Refreshing' : 'Refresh'}
</Button>
</EmptyStateFooter>
</EmptyState>
);
};
export default Repositories;