Wizard: add support for content templates

This commit is contained in:
Bryttanie House 2025-02-26 15:02:50 -05:00 committed by Klara Simickova
parent aa545382e8
commit 0238c04dfe
21 changed files with 1160 additions and 225 deletions

View file

@ -31,15 +31,21 @@ import {
import RepositoriesStatus from './RepositoriesStatus';
import RepositoryUnavailable from './RepositoryUnavailable';
import { ContentOrigin, PAGINATION_COUNT } from '../../../../constants';
import {
ContentOrigin,
PAGINATION_COUNT,
TEMPLATES_URL,
} from '../../../../constants';
import {
ApiRepositoryResponseRead,
useListRepositoriesQuery,
useGetTemplateQuery,
} from '../../../../store/contentSourcesApi';
import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
import {
changeCustomRepositories,
changePayloadRepositories,
changeRedHatRepositories,
selectArchitecture,
selectCustomRepositories,
selectDistribution,
@ -47,6 +53,7 @@ import {
selectPackages,
selectPayloadRepositories,
selectRecommendedRepositories,
selectTemplate,
selectUseLatest,
selectWizardMode,
} from '../../../../store/wizardSlice';
@ -66,6 +73,7 @@ const Repositories = () => {
const payloadRepositories = useAppSelector(selectPayloadRepositories);
const recommendedRepos = useAppSelector(selectRecommendedRepositories);
const templateUuid = useAppSelector(selectTemplate);
const [modalOpen, setModalOpen] = useState(false);
const [reposToRemove, setReposToRemove] = useState<string[]>([]);
@ -75,6 +83,7 @@ const Repositories = () => {
const [toggleSelected, setToggleSelected] = useState<
'toggle-group-all' | 'toggle-group-selected'
>('toggle-group-all');
const [isTemplateSelected, setIsTemplateSelected] = useState(false);
const debouncedFilterValue = useDebounce(filterValue);
@ -107,7 +116,7 @@ const Repositories = () => {
offset: 0,
uuid: [...initialSelectedState].join(','),
},
{ refetchOnMountOrArgChange: false }
{ refetchOnMountOrArgChange: false, skip: isTemplateSelected }
);
useEffect(() => {
@ -116,6 +125,10 @@ const Repositories = () => {
}
}, [selected, toggleSelected]);
useEffect(() => {
setIsTemplateSelected(templateUuid !== '');
}, [templateUuid]);
const {
data: { data: contentList = [], meta: { count } = { count: 0 } } = {},
isError,
@ -136,7 +149,7 @@ const Repositories = () => {
? [...selected].join(',')
: '',
},
{ refetchOnMountOrArgChange: 60 }
{ refetchOnMountOrArgChange: 60, skip: isTemplateSelected }
);
const refresh = () => {
@ -389,207 +402,400 @@ const Repositories = () => {
onClose();
};
if (isError) return <Error />;
if (isLoading) return <Loading />;
return (
<Grid>
<Modal
titleIconVariant="warning"
title="Are you sure?"
isOpen={modalOpen}
onClose={onClose}
variant="small"
actions={[
<Button key="remove" variant="primary" onClick={handleRemoveAnyway}>
Remove anyway
</Button>,
<Button key="back" variant="link" onClick={onClose}>
Back
</Button>,
]}
>
You are removing a previously added repository.
<br />
We do not recommend removing repositories if you have added packages
from them.
</Modal>
{wizardMode === 'edit' && (
<Alert
title="Removing previously added repositories may lead to issues with selected packages"
variant="warning"
isPlain
isInline
/>
)}
<Toolbar>
<ToolbarContent>
<ToolbarItem variant="bulk-select">
<BulkSelect
selected={selected}
contentList={contentList}
deselectAll={clearSelected}
perPage={perPage}
handleAddRemove={handleAddRemove}
isDisabled={
isFetching ||
(!selected.size && !contentList.length) ||
contentList.every(
(repo) =>
repo.uuid &&
isRepoDisabled(repo, selected.has(repo.uuid))[0]
)
}
/>
</ToolbarItem>
<ToolbarItem variant="search-filter">
<SearchInput
aria-label="Search repositories"
onChange={handleFilterRepositories}
value={filterValue}
onClear={() => setFilterValue('')}
/>
</ToolbarItem>
<ToolbarItem>
<Button
variant="primary"
isInline
onClick={() => refresh()}
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('toggle-group-all')}
/>
<ToggleGroupItem
text="Selected"
isDisabled={!selected.size}
aria-label="Selected repositories"
buttonId="toggle-group-selected"
isSelected={toggleSelected === 'toggle-group-selected'}
onChange={() => handleToggleClick('toggle-group-selected')}
/>
</ToggleGroup>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<Panel>
<PanelMain>
{previousReposNowUnavailable ? (
<RepositoryUnavailable quantity={previousReposNowUnavailable} />
) : (
''
)}
{contentList.length === 0 ? (
<Empty hasFilterValue={!!debouncedFilterValue} refetch={refresh} />
) : (
<Table variant="compact" data-testid="repositories-table">
<Thead>
<Tr>
<Th aria-label="Selected" />
<Th width={45}>Name</Th>
<Th width={15}>Architecture</Th>
<Th>Version</Th>
<Th width={10}>Packages</Th>
<Th>Status</Th>
</Tr>
</Thead>
<Tbody>
{contentList.map((repo, rowIndex) => {
const {
uuid = '',
url = '',
name,
status = '',
origin = '',
distribution_arch,
distribution_versions,
package_count,
last_introspection_time,
failed_introspections_count,
} = repo;
const [isDisabled, disabledReason] = isRepoDisabled(
repo,
selected.has(uuid)
);
return (
<Tr
key={`${uuid}-${rowIndex}`}
data-testid="repositories-row"
>
<Td
select={{
isSelected: selected.has(uuid),
rowIndex: rowIndex,
onSelect: (_, isSelecting) =>
handleAddRemove(repo, isSelecting),
isDisabled: isDisabled,
}}
title={disabledReason}
/>
<Td dataLabel={'Name'}>
{name}
{origin === ContentOrigin.UPLOAD ? (
<UploadRepositoryLabel />
) : (
<>
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={url}
>
{url}
</Button>
</>
)}
</Td>
<Td dataLabel={'Architecture'}>
{distribution_arch || '-'}
</Td>
<Td dataLabel={'Version'}>
{distribution_versions || '-'}
</Td>
<Td dataLabel={'Packages'}>{package_count || '-'}</Td>
<Td dataLabel={'Status'}>
<RepositoriesStatus
repoStatus={status || 'Unavailable'}
repoUrl={url}
repoIntrospections={last_introspection_time}
repoFailCount={failed_introspections_count}
/>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
)}
</PanelMain>
</Panel>
<Pagination
itemCount={count ?? PAGINATION_COUNT}
perPage={perPage}
page={page}
onSetPage={(_, newPage) => setPage(newPage)}
onPerPageSelect={handlePerPageSelect}
variant={PaginationVariant.bottom}
/>
</Grid>
const {
data: selectedTemplateData,
isError: isTemplateError,
isLoading: isTemplateLoading,
} = useGetTemplateQuery(
{
uuid: templateUuid,
},
{ refetchOnMountOrArgChange: true, skip: templateUuid === '' }
);
const {
data: {
data: reposInTemplate = [],
meta: { count: reposInTemplateCount } = { count: 0 },
} = {},
isError: isReposInTemplateError,
isLoading: isReposInTemplateLoading,
} = useListRepositoriesQuery(
{
contentType: 'rpm',
limit: perPage,
offset: perPage * (page - 1),
uuid:
selectedTemplateData && selectedTemplateData.repository_uuids
? selectedTemplateData.repository_uuids?.join(',')
: '',
},
{ refetchOnMountOrArgChange: true, skip: !isTemplateSelected }
);
useEffect(() => {
if (isTemplateSelected && reposInTemplate.length > 0) {
const customReposInTemplate = reposInTemplate.filter(
(repo) => repo.origin !== ContentOrigin.REDHAT
);
const redHatReposInTemplate = reposInTemplate.filter(
(repo) => repo.origin === ContentOrigin.REDHAT
);
dispatch(
changeCustomRepositories(
customReposInTemplate.map((repo) =>
convertSchemaToIBCustomRepo(repo!)
)
)
);
dispatch(
changePayloadRepositories(
customReposInTemplate.map((repo) =>
convertSchemaToIBPayloadRepo(repo!)
)
)
);
dispatch(
changeRedHatRepositories(
redHatReposInTemplate.map((repo) =>
convertSchemaToIBPayloadRepo(repo!)
)
)
);
}
}, [templateUuid, reposInTemplate]);
if (isError || isTemplateError || isReposInTemplateError) return <Error />;
if (isLoading || isTemplateLoading || isReposInTemplateLoading)
return <Loading />;
if (!isTemplateSelected) {
return (
<Grid>
<Modal
titleIconVariant="warning"
title="Are you sure?"
isOpen={modalOpen}
onClose={onClose}
variant="small"
actions={[
<Button key="remove" variant="primary" onClick={handleRemoveAnyway}>
Remove anyway
</Button>,
<Button key="back" variant="link" onClick={onClose}>
Back
</Button>,
]}
>
You are removing a previously added repository.
<br />
We do not recommend removing repositories if you have added packages
from them.
</Modal>
{wizardMode === 'edit' && (
<Alert
title="Removing previously added repositories may lead to issues with selected packages"
variant="warning"
isPlain
isInline
/>
)}
<Toolbar>
<ToolbarContent>
<ToolbarItem variant="bulk-select">
<BulkSelect
selected={selected}
contentList={contentList}
deselectAll={clearSelected}
perPage={perPage}
handleAddRemove={handleAddRemove}
isDisabled={
isFetching ||
(!selected.size && !contentList.length) ||
contentList.every(
(repo) =>
repo.uuid &&
isRepoDisabled(repo, selected.has(repo.uuid))[0]
)
}
/>
</ToolbarItem>
<ToolbarItem variant="search-filter">
<SearchInput
aria-label="Search repositories"
onChange={handleFilterRepositories}
value={filterValue}
onClear={() => setFilterValue('')}
/>
</ToolbarItem>
<ToolbarItem>
<Button
variant="primary"
isInline
onClick={() => refresh()}
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('toggle-group-all')}
/>
<ToggleGroupItem
text="Selected"
isDisabled={!selected.size}
aria-label="Selected repositories"
buttonId="toggle-group-selected"
isSelected={toggleSelected === 'toggle-group-selected'}
onChange={() => handleToggleClick('toggle-group-selected')}
/>
</ToggleGroup>
</ToolbarItem>
</ToolbarContent>
</Toolbar>
<Panel>
<PanelMain>
{previousReposNowUnavailable ? (
<RepositoryUnavailable quantity={previousReposNowUnavailable} />
) : (
''
)}
{contentList.length === 0 ? (
<Empty
hasFilterValue={!!debouncedFilterValue}
refetch={refresh}
/>
) : (
<Table variant="compact" data-testid="repositories-table">
<Thead>
<Tr>
<Th aria-label="Selected" />
<Th width={45}>Name</Th>
<Th width={15}>Architecture</Th>
<Th>Version</Th>
<Th width={10}>Packages</Th>
<Th>Status</Th>
</Tr>
</Thead>
<Tbody>
{contentList.map((repo, rowIndex) => {
const {
uuid = '',
url = '',
name,
status = '',
origin = '',
distribution_arch,
distribution_versions,
package_count,
last_introspection_time,
failed_introspections_count,
} = repo;
const [isDisabled, disabledReason] = isRepoDisabled(
repo,
selected.has(uuid)
);
return (
<Tr
key={`${uuid}-${rowIndex}`}
data-testid="repositories-row"
>
<Td
select={{
isSelected: selected.has(uuid),
rowIndex: rowIndex,
onSelect: (_, isSelecting) =>
handleAddRemove(repo, isSelecting),
isDisabled: isDisabled,
}}
title={disabledReason}
/>
<Td dataLabel={'Name'}>
{name}
{origin === ContentOrigin.UPLOAD ? (
<UploadRepositoryLabel />
) : (
<>
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={url}
>
{url}
</Button>
</>
)}
</Td>
<Td dataLabel={'Architecture'}>
{distribution_arch || '-'}
</Td>
<Td dataLabel={'Version'}>
{distribution_versions || '-'}
</Td>
<Td dataLabel={'Packages'}>{package_count || '-'}</Td>
<Td dataLabel={'Status'}>
<RepositoriesStatus
repoStatus={status || 'Unavailable'}
repoUrl={url}
repoIntrospections={last_introspection_time}
repoFailCount={failed_introspections_count}
/>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
)}
</PanelMain>
</Panel>
<Pagination
itemCount={count ?? PAGINATION_COUNT}
perPage={perPage}
page={page}
onSetPage={(_, newPage) => setPage(newPage)}
onPerPageSelect={handlePerPageSelect}
variant={PaginationVariant.bottom}
/>
</Grid>
);
} else {
return (
<>
<Alert
variant="info"
isInline
title={
<>
The repositories seen below are from the selected content template
and have been added automatically. If you do not want these
repositories in your image, you can{' '}
<Button
component="a"
target="_blank"
variant="link"
isInline
icon={<ExternalLinkAltIcon />}
href={`${TEMPLATES_URL}/${templateUuid}/edit`}
>
{' '}
modify this content template
</Button>{' '}
or choose another snapshot option.
</>
}
/>
<Grid>
<Panel>
<PanelMain>
<Table variant="compact" data-testid="repositories-table">
<Thead>
<Tr>
<Th aria-label="Selected" />
<Th width={45}>Name</Th>
<Th width={15}>Architecture</Th>
<Th>Version</Th>
<Th width={10}>Packages</Th>
<Th>Status</Th>
</Tr>
</Thead>
<Tbody>
{reposInTemplate.map((repo, rowIndex) => {
const {
uuid = '',
url = '',
name,
status = '',
origin = '',
distribution_arch,
distribution_versions,
package_count,
last_introspection_time,
failed_introspections_count,
} = repo;
return (
<Tr
key={`${uuid}-${rowIndex}`}
data-testid="repositories-row"
>
<Td
select={{
isSelected: true,
rowIndex: rowIndex,
isDisabled: true,
}}
/>
<Td dataLabel={'Name'}>
{name}
{origin === ContentOrigin.UPLOAD ? (
<UploadRepositoryLabel />
) : (
<>
<br />
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
iconPosition="right"
isInline
href={url}
>
{url}
</Button>
</>
)}
</Td>
<Td dataLabel={'Architecture'}>
{distribution_arch || '-'}
</Td>
<Td dataLabel={'Version'}>
{distribution_versions || '-'}
</Td>
<Td dataLabel={'Packages'}>{package_count || '-'}</Td>
<Td dataLabel={'Status'}>
<RepositoriesStatus
repoStatus={status || 'Unavailable'}
repoUrl={url}
repoIntrospections={last_introspection_time}
repoFailCount={failed_introspections_count}
/>
</Td>
</Tr>
);
})}
</Tbody>
</Table>
</PanelMain>
</Panel>
<Pagination
itemCount={reposInTemplateCount ?? PAGINATION_COUNT}
perPage={perPage}
page={page}
onSetPage={(_, newPage) => setPage(newPage)}
onPerPageSelect={handlePerPageSelect}
variant={PaginationVariant.bottom}
/>
</Grid>
</>
);
}
};
export default Repositories;

View file

@ -18,14 +18,14 @@ type EmptyProps = {
hasFilterValue: boolean;
};
export default function Empty({ hasFilterValue, refetch }: EmptyProps) {
const Empty = ({ hasFilterValue, refetch }: EmptyProps) => {
return (
<EmptyState variant={EmptyStateVariant.lg} data-testid="empty-state">
<EmptyStateHeader
titleText={
hasFilterValue
? 'No matching repositories found'
: 'No Custom Repositories'
: 'No custom repositories'
}
icon={<EmptyStateIcon icon={RepositoryIcon} />}
headingLevel="h4"
@ -52,4 +52,6 @@ export default function Empty({ hasFilterValue, refetch }: EmptyProps) {
</EmptyStateFooter>
</EmptyState>
);
}
};
export default Empty;

View file

@ -14,6 +14,7 @@ import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
import { ContentOrigin } from '../../../../constants';
import {
ApiSnapshotForDate,
useGetTemplateQuery,
useListRepositoriesQuery,
} from '../../../../store/contentSourcesApi';
import { useAppSelector } from '../../../../store/hooks';
@ -24,6 +25,7 @@ import {
selectGroups,
selectPartitions,
selectRecommendedRepositories,
selectTemplate,
} from '../../../../store/wizardSlice';
import PackageInfoNotAvailablePopover from '../Packages/components/PackageInfoNotAvailablePopover';
@ -37,7 +39,7 @@ const RepoName = ({ repoUuid }: repoPropType) => {
// @ts-ignore if repoUrl is undefined the query is going to get skipped, so it's safe to ignore the linter here
uuid: repoUuid ?? '',
contentType: 'rpm',
origin: ContentOrigin.CUSTOM,
origin: ContentOrigin.ALL,
},
{ skip: !repoUuid }
);
@ -127,8 +129,22 @@ export const SnapshotTable = ({
}: {
snapshotForDate: ApiSnapshotForDate[];
}) => {
const template = useAppSelector(selectTemplate);
const { data: templateData } = useGetTemplateQuery(
{
uuid: template,
},
{ refetchOnMountOrArgChange: true, skip: template === '' }
);
const { data, isSuccess, isLoading, isError } = useListRepositoriesQuery({
uuid: snapshotForDate.map(({ repository_uuid }) => repository_uuid).join(),
uuid:
snapshotForDate.length > 0
? snapshotForDate.map(({ repository_uuid }) => repository_uuid).join()
: template && templateData && templateData.repository_uuids
? templateData.repository_uuids.join(',')
: '',
origin: ContentOrigin.REDHAT + ',' + ContentOrigin.CUSTOM, // Make sure to show both redhat and external
});

View file

@ -36,7 +36,10 @@ import {
targetOptions,
UNIT_GIB,
} from '../../../../constants';
import { useListSnapshotsByDateMutation } from '../../../../store/contentSourcesApi';
import {
useListSnapshotsByDateMutation,
useGetTemplateQuery,
} from '../../../../store/contentSourcesApi';
import { useAppSelector } from '../../../../store/hooks';
import { useGetSourceListQuery } from '../../../../store/provisioningApi';
import { useShowActivationKeyQuery } from '../../../../store/rhsmApi';
@ -76,6 +79,8 @@ import {
selectFirewall,
selectServices,
selectUsers,
selectTemplate,
selectRedHatRepositories,
} from '../../../../store/wizardSlice';
import { toMonthAndYear, yyyyMMddFormat } from '../../../../Utilities/time';
import { useGetEnvironment } from '../../../../Utilities/useGetEnvironment';
@ -444,6 +449,8 @@ export const ContentList = () => {
const recommendedRepositories = useAppSelector(selectRecommendedRepositories);
const snapshotDate = useAppSelector(selectSnapshotDate);
const useLatest = useAppSelector(selectUseLatest);
const template = useAppSelector(selectTemplate);
const redHatRepositories = useAppSelector(selectRedHatRepositories);
const customAndRecommendedRepositoryUUIDS = useMemo(
() =>
@ -458,10 +465,14 @@ export const ContentList = () => {
useListSnapshotsByDateMutation();
useEffect(() => {
if (!snapshotDate && !useLatest) return;
listSnapshotsByDate({
apiListSnapshotByDateRequest: {
repository_uuids: customAndRecommendedRepositoryUUIDS,
date: useLatest ? yyyyMMddFormat(new Date()) : snapshotDate,
date: useLatest
? yyyyMMddFormat(new Date()) + 'T00:00:00Z'
: snapshotDate,
},
});
}, [
@ -476,22 +487,33 @@ export const ContentList = () => {
);
const noRepositoriesSelected =
customAndRecommendedRepositoryUUIDS.length === 0;
customAndRecommendedRepositoryUUIDS.length === 0 &&
redHatRepositories.length === 0;
const hasSnapshotDateAfter = data?.data?.some(({ is_after }) => is_after);
const { data: templateData, isLoading: isTemplateLoading } =
useGetTemplateQuery(
{
uuid: template,
},
{ refetchOnMountOrArgChange: true, skip: template === '' }
);
const snapshottingText = useMemo(() => {
switch (true) {
case isLoading:
case isLoading || isTemplateLoading:
return '';
case useLatest:
return 'Use latest';
case !!snapshotDate:
return `State as of ${yyyyMMddFormat(new Date(snapshotDate))}`;
case !!template:
return `Use a content template: ${templateData?.name}`;
default:
return '';
}
}, [isLoading, useLatest, snapshotDate]);
}, [isLoading, isTemplateLoading, useLatest, snapshotDate, template]);
return (
<>
@ -513,6 +535,8 @@ export const ContentList = () => {
headerContent={
useLatest
? 'Use the latest repository content'
: template
? 'Use content from the content template'
: `Repositories as of ${yyyyMMddFormat(
new Date(snapshotDate)
)}`

View file

@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import {
Button,
@ -11,14 +11,19 @@ import {
Title,
} from '@patternfly/react-core';
import Templates from './components/Templates';
import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
import {
selectSnapshotDate,
selectUseLatest,
changeUseLatest,
changeSnapshotDate,
changeTemplate,
selectUseLatest,
selectTemplate,
} from '../../../../store/wizardSlice';
import { yyyyMMddFormat } from '../../../../Utilities/time';
import { useFlag } from '../../../../Utilities/useGetEnvironment';
import { isSnapshotDateValid } from '../../validators';
export default function Snapshot() {
@ -26,6 +31,34 @@ export default function Snapshot() {
const snapshotDate = useAppSelector(selectSnapshotDate);
const useLatest = useAppSelector(selectUseLatest);
const templateUuid = useAppSelector(selectTemplate);
const [selectedOption, setSelectedOption] = useState<
'latest' | 'snapshotDate' | 'template'
>(useLatest ? 'latest' : templateUuid ? 'template' : 'snapshotDate');
const isTemplatesEnabled = useFlag('image-builder.templates.enabled');
const handleOptionChange = (
option: 'latest' | 'snapshotDate' | 'template'
): void => {
setSelectedOption(option);
switch (option) {
case 'latest':
dispatch(changeUseLatest(true));
dispatch(changeTemplate(''));
dispatch(changeSnapshotDate(''));
break;
case 'snapshotDate':
dispatch(changeUseLatest(false));
dispatch(changeTemplate(''));
break;
case 'template':
dispatch(changeUseLatest(false));
dispatch(changeSnapshotDate(''));
break;
}
};
return (
<>
<FormGroup>
@ -35,8 +68,8 @@ export default function Snapshot() {
name="use-latest-snapshot"
label="Disable repeatable build"
description="Use the newest repository content available when building this image"
isChecked={useLatest}
onChange={() => !useLatest && dispatch(changeUseLatest(true))}
isChecked={selectedOption === 'latest'}
onChange={() => handleOptionChange('latest')}
/>
<Radio
id="use snapshot date radio"
@ -44,11 +77,25 @@ export default function Snapshot() {
name="use-snapshot-date"
label="Enable repeatable build"
description="Build this image with the repository content of a selected date"
isChecked={!useLatest}
onChange={() => useLatest && dispatch(changeUseLatest(false))}
isChecked={selectedOption === 'snapshotDate'}
onChange={() => handleOptionChange('snapshotDate')}
/>
{isTemplatesEnabled ? (
<Radio
id="use content template radio"
ouiaId="use-content-template-radio"
name="use-content-template"
label="Use a content template"
description="Select a content template and build this image with repository snapshots included in that template"
isChecked={selectedOption === 'template'}
onChange={() => handleOptionChange('template')}
/>
) : (
<></>
)}
</FormGroup>
{useLatest ? (
{selectedOption === 'latest' ? (
<>
<Title headingLevel="h1" size="xl">
Use latest content
@ -60,7 +107,7 @@ export default function Snapshot() {
</Text>
</Grid>
</>
) : (
) : selectedOption === 'snapshotDate' ? (
<>
<Title headingLevel="h1" size="xl">
Use a snapshot
@ -110,6 +157,15 @@ export default function Snapshot() {
</Text>
</Grid>
</>
) : isTemplatesEnabled && selectedOption === 'template' ? (
<>
<Title headingLevel="h1" size="xl">
Use a content template
</Title>
<Templates />
</>
) : (
<></>
)}
</>
);

View file

@ -0,0 +1,155 @@
import React, { useState } from 'react';
import {
Button,
Grid,
Pagination,
PaginationVariant,
Panel,
PanelMain,
} from '@patternfly/react-core';
import { Table, Thead, Tr, Th, Tbody, Td } from '@patternfly/react-table';
import TemplatesEmpty from './TemplatesEmpty';
import { PAGINATION_COUNT } from '../../../../../constants';
import { useListTemplatesQuery } from '../../../../../store/contentSourcesApi';
import { useAppDispatch, useAppSelector } from '../../../../../store/hooks';
import {
selectArchitecture,
selectDistribution,
selectTemplate,
changeTemplate,
} from '../../../../../store/wizardSlice';
import { releaseToVersion } from '../../../../../Utilities/releaseToVersion';
import { Error } from '../../Repositories/components/Error';
import { Loading } from '../../Repositories/components/Loading';
const Templates = () => {
const arch = useAppSelector(selectArchitecture);
const distribution = useAppSelector(selectDistribution);
const version = releaseToVersion(distribution);
const [perPage, setPerPage] = useState(10);
const [page, setPage] = useState(1);
const dispatch = useAppDispatch();
const templateUuid = useAppSelector(selectTemplate);
const {
data: {
data: templateList = [],
meta: { count: templateCount } = { count: 0 },
} = {},
isError,
isFetching,
isLoading,
refetch: refetchTemplates,
} = useListTemplatesQuery(
{
arch: arch,
version: version,
limit: perPage,
offset: perPage * (page - 1),
},
{ refetchOnMountOrArgChange: 60 }
);
const handleRowSelect = (templateUuid: string | undefined): void => {
if (templateUuid) {
dispatch(changeTemplate(templateUuid));
}
};
const handlePerPageSelect = (
_: React.MouseEvent,
newPerPage: number,
newPage: number
) => {
setPerPage(newPerPage);
setPage(newPage);
};
const refresh = () => {
refetchTemplates();
};
if (isError) return <Error />;
if (isLoading) return <Loading />;
return (
<Grid>
<Panel>
{templateList.length > 0 ? (
<Button
variant="primary"
isInline
onClick={() => refresh()}
isLoading={isFetching}
>
{isFetching ? 'Refreshing' : 'Refresh'}
</Button>
) : (
<></>
)}
<PanelMain>
{templateList.length === 0 ? (
<TemplatesEmpty refetch={refresh} />
) : (
<>
<Pagination
itemCount={templateCount ?? PAGINATION_COUNT}
perPage={perPage}
page={page}
onSetPage={(_, newPage) => setPage(newPage)}
onPerPageSelect={handlePerPageSelect}
isCompact
/>
<Table variant="compact" data-testid="templates-table">
<Thead>
<Tr>
<Th aria-label="Selected" />
<Th width={15}>Name</Th>
<Th width={50}>Description</Th>
<Th width={15}>Snapshot date</Th>
</Tr>
</Thead>
<Tbody>
{templateList.map((template, rowIndex) => {
const { uuid, name, description, date, use_latest } =
template;
return (
<Tr key={uuid}>
<Td
select={{
variant: 'radio',
isSelected: uuid === templateUuid,
rowIndex: rowIndex,
onSelect: () => handleRowSelect(uuid),
}}
/>
<Td dataLabel={'Name'}>{name}</Td>
<Td dataLabel={'Description'}>{description}</Td>
<Td dataLabel={'Snapshot date'}>
{use_latest ? 'Use latest' : date?.split('T')[0]}
</Td>
</Tr>
);
})}
</Tbody>
</Table>
<Pagination
itemCount={templateCount ?? PAGINATION_COUNT}
perPage={perPage}
page={page}
onSetPage={(_, newPage) => setPage(newPage)}
onPerPageSelect={handlePerPageSelect}
variant={PaginationVariant.bottom}
/>
</>
)}
</PanelMain>
</Panel>
</Grid>
);
};
export default Templates;

View file

@ -0,0 +1,51 @@
import React from 'react';
import {
EmptyState,
EmptyStateVariant,
EmptyStateHeader,
EmptyStateBody,
EmptyStateFooter,
Button,
} from '@patternfly/react-core';
import { ExternalLinkAltIcon } from '@patternfly/react-icons';
import { TEMPLATES_URL } from '../../../../../constants';
type TemplatesEmptyProps = {
refetch: () => void;
};
const TemplatesEmpty = ({ refetch }: TemplatesEmptyProps) => {
const GoToTemplatesButton = () => {
return (
<Button
component="a"
target="_blank"
variant="link"
icon={<ExternalLinkAltIcon />}
href={TEMPLATES_URL}
>
Go to content templates
</Button>
);
};
return (
<EmptyState variant={EmptyStateVariant.lg} data-testid="empty-state">
<EmptyStateHeader titleText={'No content templates'} headingLevel="h4" />
<EmptyStateBody>
{`Content templates can be added in the "Templates" area of the
console.`}
</EmptyStateBody>
<EmptyStateFooter>
<GoToTemplatesButton />
<Button variant="secondary" isInline onClick={() => refetch()}>
Refresh
</Button>
</EmptyStateFooter>
</EmptyState>
);
};
export default TemplatesEmpty;