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

@ -17,6 +17,8 @@ const config: ConfigFile = {
'listFeatures',
'listSnapshotsByDate',
'bulkImportRepositories',
'listTemplates',
'getTemplate',
],
};

View file

@ -1218,6 +1218,11 @@ components:
be used. If no snapshots made before the specified date can be found, the snapshot
closest to, but after the specified date will be used. If no snapshots can be found at
all, the request will fail. The format must be YYYY-MM-DD (ISO 8601 extended).
content_template:
type: string
description: |
ID of the content template. A content template and snapshot date cannot both be specified.
If a content template is specified, the snapshot date used will be the one from the content template.
ImageTypes:
type: string
enum:

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;

View file

@ -85,6 +85,7 @@ import {
selectUsers,
selectMetadata,
selectFirewall,
selectTemplate,
selectSatelliteCaCertificate,
selectSatelliteRegistrationCommand,
} from '../../../store/wizardSlice';
@ -301,13 +302,15 @@ function commonRequestToState(
sourceId: awsUploadOptions?.share_with_sources?.[0],
},
snapshotting: {
useLatest: !snapshot_date,
useLatest: !snapshot_date && !request.image_requests[0]?.content_template,
snapshotDate: snapshot_date,
template: request.image_requests[0]?.content_template || '',
},
repositories: {
customRepositories: request.customizations?.custom_repositories || [],
payloadRepositories: request.customizations?.payload_repositories || [],
recommendedRepositories: [],
redHatRepositories: [],
},
packages:
request.customizations?.packages
@ -432,6 +435,7 @@ const getImageRequests = (state: RootState): ImageRequest[] => {
const imageTypes = selectImageTypes(state);
const snapshotDate = selectSnapshotDate(state);
const useLatest = selectUseLatest(state);
const template = selectTemplate(state);
return imageTypes.map((type) => ({
architecture: selectArchitecture(state),
image_type: type,
@ -439,7 +443,8 @@ const getImageRequests = (state: RootState): ImageRequest[] => {
type: uploadTypeByTargetEnv(type),
options: getImageOptions(type, state),
},
snapshot_date: useLatest ? undefined : snapshotDate,
snapshot_date: !useLatest && !template ? snapshotDate : undefined,
content_template: template || undefined,
}));
};

View file

@ -32,6 +32,7 @@ import {
selectSatelliteRegistrationCommand,
selectImageTypes,
UserWithAdditionalInfo,
selectTemplate,
} from '../../../store/wizardSlice';
import { keyboardsList } from '../steps/Locale/keyboardsList';
import { languagesList } from '../steps/Locale/languagesList';
@ -226,8 +227,9 @@ export function useFilesystemValidation(): StepValidation {
export function useSnapshotValidation(): StepValidation {
const snapshotDate = useAppSelector(selectSnapshotDate);
const useLatest = useAppSelector(selectUseLatest);
const template = useAppSelector(selectTemplate);
if (!useLatest && !isSnapshotValid(snapshotDate)) {
if (!useLatest && !isSnapshotValid(snapshotDate) && template === '') {
return {
errors: { snapshotDate: 'Invalid snapshot date' },
disabledNext: true,

View file

@ -44,6 +44,7 @@ const onPremFlag = (flag: string): boolean => {
case 'image-builder.kernel.enabled':
case 'image-builder.firewall.enabled':
case 'image-builder.services.enabled':
case 'image-builder.templates.enabled':
return true;
default:
return false;

View file

@ -12,6 +12,7 @@ export const EDIT_BLUEPRINT = `${IMAGE_BUILDER_API}/blueprints`;
export const CDN_PROD_URL = 'https://cdn.redhat.com/';
export const CDN_STAGE_URL = 'https://cdn.stage.redhat.com/';
export const CONTENT_URL = '/insights/content/repositories';
export const TEMPLATES_URL = '/insights/content/templates';
export const DEVELOPERS_URL = 'https://developers.redhat.com/about';
export const FILE_SYSTEM_CUSTOMIZATION_URL =
'https://docs.redhat.com/en/documentation/red_hat_insights/1-latest/html/deploying_and_managing_rhel_systems_in_hybrid_clouds/index#creating-a-blueprint_creating-blueprints-and-blueprint-images';

View file

@ -16,6 +16,8 @@ export const {
useCreateRepositoryMutation,
useBulkImportRepositoriesMutation,
useListRepositoriesRpmsQuery,
useListTemplatesQuery,
useGetTemplateQuery,
contentSourcesApi,
} = serviceQueries;

View file

@ -89,6 +89,24 @@ const injectedRtkApi = api.injectEndpoints({
body: queryArg.apiListSnapshotByDateRequest,
}),
}),
listTemplates: build.query<ListTemplatesApiResponse, ListTemplatesApiArg>({
query: (queryArg) => ({
url: `/templates/`,
params: {
offset: queryArg.offset,
limit: queryArg.limit,
version: queryArg.version,
arch: queryArg.arch,
name: queryArg.name,
repository_uuids: queryArg.repositoryUuids,
snapshot_uuids: queryArg.snapshotUuids,
sort_by: queryArg.sortBy,
},
}),
}),
getTemplate: build.query<GetTemplateApiResponse, GetTemplateApiArg>({
query: (queryArg) => ({ url: `/templates/${queryArg.uuid}` }),
}),
}),
overrideExisting: false,
});
@ -170,6 +188,32 @@ export type ListSnapshotsByDateApiArg = {
/** request body */
apiListSnapshotByDateRequest: ApiListSnapshotByDateRequest;
};
export type ListTemplatesApiResponse =
/** status 200 OK */ ApiTemplateCollectionResponseRead;
export type ListTemplatesApiArg = {
/** Starting point for retrieving a subset of results. Determines how many items to skip from the beginning of the result set. Default value:`0`. */
offset?: number;
/** Number of items to include in response. Use it to control the number of items, particularly when dealing with large datasets. Default value: `100`. */
limit?: number;
/** Filter templates by version. */
version?: string;
/** Filter templates by architecture. */
arch?: string;
/** Filter templates by name. */
name?: string;
/** Filter templates by associated repositories using a comma separated list of repository UUIDs */
repositoryUuids?: string;
/** Filter templates by associated snapshots using a comma separated list of snapshot UUIDs */
snapshotUuids?: string;
/** Sort the response data based on specific parameters. Sort criteria can include `name`, `arch`, and `version`. */
sortBy?: string;
};
export type GetTemplateApiResponse =
/** status 200 OK */ ApiTemplateResponseRead;
export type GetTemplateApiArg = {
/** Template ID. */
uuid: string;
};
export type ApiFeature = {
/** Whether the current user can access the feature */
accessible?: boolean | undefined;
@ -636,6 +680,91 @@ export type ApiListSnapshotByDateRequest = {
/** Repository UUIDs to find snapshots for */
repository_uuids: string[];
};
export type ApiTemplateResponse = {
/** Architecture of the template */
arch?: string | undefined;
/** Datetime template was created */
created_at?: string | undefined;
/** User that created the template */
created_by?: string | undefined;
/** Latest date to include snapshots for */
date?: string | undefined;
/** Description of the template */
description?: string | undefined;
/** Error of last update_latest_snapshot task that updated the template */
last_update_snapshot_error?: string | undefined;
last_update_task?: ApiTaskInfoResponse | undefined;
/** UUID of the last update_template_content task that updated the template */
last_update_task_uuid?: string | undefined;
/** User that most recently updated the template */
last_updated_by?: string | undefined;
/** Name of the template */
name?: string | undefined;
/** Organization ID of the owner */
org_id?: string | undefined;
/** Repositories added to the template */
repository_uuids?: string[] | undefined;
/** Environment ID used by subscription-manager and candlepin */
rhsm_environment_id?: string | undefined;
/** Datetime template was last updated */
updated_at?: string | undefined;
/** Use latest snapshot for all repositories in the template */
use_latest?: boolean | undefined;
/** Version of the template */
version?: string | undefined;
};
export type ApiTemplateResponseRead = {
/** Architecture of the template */
arch?: string | undefined;
/** Datetime template was created */
created_at?: string | undefined;
/** User that created the template */
created_by?: string | undefined;
/** Latest date to include snapshots for */
date?: string | undefined;
/** Description of the template */
description?: string | undefined;
/** Error of last update_latest_snapshot task that updated the template */
last_update_snapshot_error?: string | undefined;
last_update_task?: ApiTaskInfoResponse | undefined;
/** UUID of the last update_template_content task that updated the template */
last_update_task_uuid?: string | undefined;
/** User that most recently updated the template */
last_updated_by?: string | undefined;
/** Name of the template */
name?: string | undefined;
/** Organization ID of the owner */
org_id?: string | undefined;
/** Repositories added to the template */
repository_uuids?: string[] | undefined;
/** Whether the candlepin environment is created and systems can be added */
rhsm_environment_created?: boolean | undefined;
/** Environment ID used by subscription-manager and candlepin */
rhsm_environment_id?: string | undefined;
/** The list of snapshots in use by the template */
snapshots?: ApiSnapshotResponse[] | undefined;
/** List of snapshots used by this template which are going to be deleted soon */
to_be_deleted_snapshots?: ApiSnapshotResponse[] | undefined;
/** Datetime template was last updated */
updated_at?: string | undefined;
/** Use latest snapshot for all repositories in the template */
use_latest?: boolean | undefined;
uuid?: string | undefined;
/** Version of the template */
version?: string | undefined;
};
export type ApiTemplateCollectionResponse = {
/** Requested Data */
data?: ApiTemplateResponse[] | undefined;
links?: ApiLinks | undefined;
meta?: ApiResponseMetadata | undefined;
};
export type ApiTemplateCollectionResponseRead = {
/** Requested Data */
data?: ApiTemplateResponseRead[] | undefined;
links?: ApiLinks | undefined;
meta?: ApiResponseMetadata | undefined;
};
export const {
useListFeaturesQuery,
useSearchPackageGroupMutation,
@ -645,4 +774,6 @@ export const {
useListRepositoriesRpmsQuery,
useSearchRpmMutation,
useListSnapshotsByDateMutation,
useListTemplatesQuery,
useGetTemplateQuery,
} = injectedRtkApi;

View file

@ -549,6 +549,10 @@ export type ImageRequest = {
all, the request will fail. The format must be YYYY-MM-DD (ISO 8601 extended).
*/
snapshot_date?: string | undefined;
/** ID of the content template. A content template and snapshot date cannot both be specified.
If a content template is specified, the snapshot date used will be the one from the content template.
*/
content_template?: string | undefined;
};
export type Container = {
/** Reference to the container to embed */

View file

@ -128,6 +128,7 @@ export type wizardState = {
snapshotting: {
useLatest: boolean;
snapshotDate: string;
template: string;
};
users: UserWithAdditionalInfo[];
firstBoot: {
@ -137,6 +138,7 @@ export type wizardState = {
customRepositories: CustomRepository[];
payloadRepositories: Repository[];
recommendedRepositories: ApiRepositoryResponseRead[];
redHatRepositories: Repository[];
};
packages: IBPackageWithRepositoryInfo[];
groups: GroupWithRepositoryInfo[];
@ -221,11 +223,13 @@ export const initialState: wizardState = {
snapshotting: {
useLatest: true,
snapshotDate: '',
template: '',
},
repositories: {
customRepositories: [],
payloadRepositories: [],
recommendedRepositories: [],
redHatRepositories: [],
},
packages: [],
groups: [],
@ -382,10 +386,15 @@ export const selectPartitions = (state: RootState) => {
export const selectUseLatest = (state: RootState) => {
return state.wizard.snapshotting.useLatest;
};
export const selectSnapshotDate = (state: RootState) => {
return state.wizard.snapshotting.snapshotDate;
};
export const selectTemplate = (state: RootState) => {
return state.wizard.snapshotting.template;
};
export const selectCustomRepositories = (state: RootState) => {
return state.wizard.repositories.customRepositories;
};
@ -398,6 +407,10 @@ export const selectRecommendedRepositories = (state: RootState) => {
return state.wizard.repositories.recommendedRepositories;
};
export const selectRedHatRepositories = (state: RootState) => {
return state.wizard.repositories.redHatRepositories;
};
export const selectPackages = (state: RootState) => {
return state.wizard.packages;
};
@ -728,6 +741,9 @@ export const wizardSlice = createSlice({
state.snapshotting.snapshotDate = date.toISOString();
}
},
changeTemplate: (state, action: PayloadAction<string>) => {
state.snapshotting.template = action.payload;
},
importCustomRepositories: (
state,
action: PayloadAction<CustomRepository[]>
@ -746,6 +762,9 @@ export const wizardSlice = createSlice({
changePayloadRepositories: (state, action: PayloadAction<Repository[]>) => {
state.repositories.payloadRepositories = action.payload;
},
changeRedHatRepositories: (state, action: PayloadAction<Repository[]>) => {
state.repositories.redHatRepositories = action.payload;
},
addRecommendedRepository: (
state,
action: PayloadAction<ApiRepositoryResponseRead>
@ -1102,6 +1121,7 @@ export const {
changePartitionOrder,
changeUseLatest,
changeSnapshotDate,
changeTemplate,
changeCustomRepositories,
importCustomRepositories,
changePayloadRepositories,
@ -1153,5 +1173,6 @@ export const {
setUserAdministratorByIndex,
addUserGroupByIndex,
removeUserGroupByIndex,
changeRedHatRepositories,
} = wizardSlice.actions;
export default wizardSlice.reducer;

View file

@ -10,7 +10,11 @@ import {
expectedPayloadRepositories,
snapshotCreateBlueprintRequest,
} from '../../../../fixtures/editMode';
import { clickNext, clickReviewAndFinish } from '../../wizardTestUtils';
import {
clickNext,
clickReviewAndFinish,
getNextButton,
} from '../../wizardTestUtils';
import {
blueprintRequest,
clickRegisterLater,
@ -123,6 +127,22 @@ const clickReset = async () => {
await waitFor(async () => user.click(resetButton));
};
const selectUseTemplate = async () => {
const user = userEvent.setup();
const templateRadio = await screen.findByRole('radio', {
name: /Use a content template/i,
});
await waitFor(async () => user.click(templateRadio));
};
const selectFirstTemplate = async () => {
const user = userEvent.setup();
const row0Radio = await screen.findByRole('radio', {
name: /select row 0/i,
});
await waitFor(async () => user.click(row0Radio));
};
describe('repository snapshot tab - ', () => {
beforeEach(() => {
vi.clearAllMocks();
@ -286,6 +306,23 @@ describe('repository snapshot tab - ', () => {
await clickRevisitButton();
await screen.findByRole('heading', { name: /Custom repositories/i });
});
test('select use a content template', async () => {
await renderCreateMode();
await goToSnapshotStep();
await selectUseTemplate();
const nextBtn = await getNextButton();
await waitFor(() => {
expect(nextBtn).toHaveAttribute('aria-disabled', 'true');
});
await selectFirstTemplate();
await waitFor(() => {
expect(nextBtn).toHaveAttribute('aria-disabled', 'false');
});
await clickNext();
await goToReviewStep();
await screen.findByText(/Use a content template/);
});
});
describe('Snapshot edit mode', () => {

195
src/test/fixtures/templates.ts vendored Normal file
View file

@ -0,0 +1,195 @@
import {
ApiLinks,
ApiResponseMetadata,
ApiTemplateResponse,
ApiTemplateResponseRead,
ListTemplatesApiArg,
} from '../../store/contentSourcesApi';
type templateArgs = {
arch: ListTemplatesApiArg['arch'];
version: ListTemplatesApiArg['version'];
limit: ListTemplatesApiArg['limit'];
offset: ListTemplatesApiArg['offset'];
};
export const mockTemplateResults = (request: templateArgs) => {
const templates = filterTemplates(request);
const limit = request.limit ? request.limit : 100;
const data = templates.slice(request.offset, limit);
const meta = generateMeta(request.limit, request.offset, templates.length);
const links = generateLinks(request.limit, request.offset);
const response = {
data: data,
meta: meta,
links: links,
};
return response;
};
const filterTemplates = (args: templateArgs): ApiTemplateResponse[] => {
let templates = testingTemplates;
if (args.arch) {
templates = templates.filter((template) => template.arch === args.arch);
}
if (args.version) {
templates = templates.filter(
(template) => template.version === args.version
);
}
return templates;
};
const testingTemplates: ApiTemplateResponseRead[] = [
{
uuid: 'c40e221b-93d6-4f7e-a704-f3041b8d75c3',
name: 'template-abc',
org_id: '13476545',
description: 'description-abc',
arch: 'x86_64',
version: '9',
date: '0001-01-01T00:00:00Z',
repository_uuids: [
'828e7db8-c0d4-48fc-a887-9070e0e75c45',
'ae39f556-6986-478a-95d1-f9c7e33d066c',
],
snapshots: [
{
uuid: '90302927-848a-4fa9-ba44-c58bb162a009',
created_at: '2025-02-27T16:23:59.148649Z',
repository_path: 'test/snapshot1',
content_counts: {
'rpm.advisory': 5,
'rpm.package': 5,
'rpm.repo_metadata_file': 1,
},
added_counts: {
'rpm.advisory': 5,
'rpm.package': 5,
'rpm.repo_metadata_file': 1,
},
removed_counts: {},
url: 'http://test.com/test/snapshot1/',
repository_name: '2zmya',
repository_uuid: '828e7db8-c0d4-48fc-a887-9070e0e75c45',
},
{
uuid: '80303926-948a-4fa8-ba44-c59bb162a008',
created_at: '2025-02-27T16:23:59.148649Z',
repository_path: 'test/snapshot2',
content_counts: {
'rpm.advisory': 5,
'rpm.package': 5,
'rpm.repo_metadata_file': 1,
},
added_counts: {
'rpm.advisory': 5,
'rpm.package': 5,
'rpm.repo_metadata_file': 1,
},
removed_counts: {},
url: 'http://test.com/test/snapshot2/',
repository_name: '01-test-valid-repo',
repository_uuid: 'ae39f556-6986-478a-95d1-f9c7e33d066c',
},
],
rhsm_environment_id: '4202ed8d725e46079cc7454a64b69093',
created_by: 'test',
last_updated_by: 'test',
created_at: '2025-02-28T17:34:33.598161Z',
updated_at: '2025-02-28T17:34:33.598161Z',
use_latest: true,
last_update_snapshot_error: '',
last_update_task_uuid: '9aa99713-65d1-4057-908e-96150573a22f',
last_update_task: {
uuid: '9aa99713-65d1-4057-908e-96150573a22f',
status: 'completed',
created_at: '2025-02-28T17:34:33Z',
ended_at: '2025-02-28T17:34:34Z',
error: '',
org_id: '13476545',
type: 'update-template-content',
object_type: 'template',
object_name: 'template-abc',
object_uuid: 'c40e221b-93d6-4f7e-a704-f3041b8d75c3',
},
rhsm_environment_created: true,
},
{
uuid: '4202ed8d-725e-4607-9cc7-454a64b69093',
name: 'template-xyz',
org_id: '13476545',
description: 'description-xyz',
arch: 'x86_64',
version: '9',
date: '2025-02-28T05:00:00Z',
repository_uuids: ['828e7db8-c0d4-48fc-a887-9070e0e75c45'],
snapshots: [
{
uuid: '90302927-848a-4fa9-ba44-c58bb162a009',
created_at: '2025-02-27T16:23:59.148649Z',
repository_path: 'test/snapshot1',
content_counts: {
'rpm.advisory': 5,
'rpm.package': 5,
'rpm.repo_metadata_file': 1,
},
added_counts: {
'rpm.advisory': 5,
'rpm.package': 5,
'rpm.repo_metadata_file': 1,
},
removed_counts: {},
url: 'http://test.com/test/snapshot1/',
repository_name: '2zmya',
repository_uuid: '828e7db8-c0d4-48fc-a887-9070e0e75c45',
},
],
rhsm_environment_id: '4202ed8d725e46079cc7454a64b69093',
created_by: 'test',
last_updated_by: 'test',
created_at: '2025-02-28T18:35:34.792223Z',
updated_at: '2025-02-28T18:35:34.792223Z',
use_latest: false,
last_update_snapshot_error: '',
last_update_task_uuid: '8bn99713-65d1-4057-908e-96150573a22f',
last_update_task: {
uuid: '8bn99713-65d1-4057-908e-96150573a22f',
status: 'completed',
created_at: '2025-02-28T17:34:33Z',
ended_at: '2025-02-28T17:34:34Z',
error: '',
org_id: '13476545',
type: 'update-template-content',
object_type: 'template',
object_name: 'template-xyz',
object_uuid: '4202ed8d-725e-4607-9cc7-454a64b69093',
},
rhsm_environment_created: true,
},
];
const generateMeta = (
limit: ApiResponseMetadata['limit'],
offset: ApiResponseMetadata['offset'],
count: ApiResponseMetadata['count']
): ApiResponseMetadata => {
return {
limit: limit,
offset: offset,
count: count,
};
};
const generateLinks = (
limit: ApiResponseMetadata['limit'],
offset: ApiResponseMetadata['offset']
): ApiLinks => {
return {
first: `/api/content-sources/v1/templates/?limit=${limit}&offset=${offset}`,
last: `/api/content-sources/v1/templates/?limit=${limit}&offset=${offset}`,
};
};

View file

@ -43,6 +43,7 @@ import {
mockRepositoryResults,
} from '../fixtures/repositories';
import { mockSourcesByProvider, mockUploadInfo } from '../fixtures/sources';
import { mockTemplateResults } from '../fixtures/templates';
export const handlers = [
http.get(`${PROVISIONING_API}/sources`, ({ request }) => {
@ -101,15 +102,31 @@ export const handlers = [
const limit = url.searchParams.get('limit');
const offset = url.searchParams.get('offset');
const search = url.searchParams.get('search');
const uuid = url.searchParams.get('uuid');
const args = {
available_for_arch,
available_for_version,
limit,
offset,
search,
uuid,
};
return HttpResponse.json(mockRepositoryResults(args));
}),
http.get(`${CONTENT_SOURCES_API}/templates/`, ({ request }) => {
const url = new URL(request.url);
const arch = url.searchParams.get('arch');
const version = url.searchParams.get('version');
const limit = url.searchParams.get('limit');
const offset = url.searchParams.get('offset');
const args = {
arch,
version,
limit,
offset,
};
return HttpResponse.json(mockTemplateResults(args));
}),
http.get(`${CONTENT_SOURCES_API}/repositories/:repo_id`, ({ params }) => {
const { repo_id } = params;
return HttpResponse.json(mockPopularRepo(repo_id));

View file

@ -79,6 +79,8 @@ vi.mock('@unleash/proxy-client-react', () => ({
return true;
case 'image-builder.satellite.enabled':
return true;
case 'image-builder.templates.enabled':
return true;
default:
return false;
}