diff --git a/src/Pages/Repositories/ContentListTable/components/AddContent/AddContent.test.tsx b/src/Pages/Repositories/ContentListTable/components/AddContent/AddContent.test.tsx
index 099747c1..9f1368a0 100644
--- a/src/Pages/Repositories/ContentListTable/components/AddContent/AddContent.test.tsx
+++ b/src/Pages/Repositories/ContentListTable/components/AddContent/AddContent.test.tsx
@@ -62,7 +62,8 @@ jest.mock('../../ContentListTable', () => ({
}));
jest.mock('@redhat-cloud-services/frontend-components/useChrome', () => () => ({
- isProd: () => false, isBeta: () => false
+ isProd: () => false,
+ isBeta: () => false,
}));
const passingValidationMetaDataSigNotPresent = [
diff --git a/src/Pages/Repositories/ContentListTable/components/AddContent/AddContent.tsx b/src/Pages/Repositories/ContentListTable/components/AddContent/AddContent.tsx
index 9117dc9d..9f20b6c1 100644
--- a/src/Pages/Repositories/ContentListTable/components/AddContent/AddContent.tsx
+++ b/src/Pages/Repositories/ContentListTable/components/AddContent/AddContent.tsx
@@ -493,7 +493,11 @@ const AddContent = ({ isEdit = false }: Props) => {
}
/>
-
+
({
+ ...jest.requireActual('react-query'),
+ useQueryClient: jest.fn(),
+}));
+
+jest.mock('react-router-dom', () => ({
+ useNavigate: jest.fn(),
+ useParams: () => ({ repoUUID: defaultContentItemWithSnapshot.uuid }),
+ useLocation: () => ({
+ search: `${DELETE_ROUTE}?snapshotUUID=${defaultSnapshotItem.uuid}`,
+ }),
+ useHref: () => 'insights/content/repositories',
+}));
+
+jest.mock('../SnapshotListModal', () => ({
+ useSnapshotListOutletContext: () => ({
+ clearCheckedRepositories: () => undefined,
+ deletionContext: {
+ checkedSnapshots: new Set([defaultSnapshotItem.uuid]),
+ },
+ }),
+}));
+
+jest.mock('Hooks/useRootPath', () => () => 'someUrl');
+
+jest.mock('services/Content/ContentQueries', () => ({
+ useBulkDeleteSnapshotsMutate: () => ({ isLoading: false }),
+ useGetSnapshotList: jest.fn(),
+}));
+
+jest.mock('services/Templates/TemplateQueries', () => ({
+ useFetchTemplatesForSnapshots: jest.fn(),
+}));
+
+jest.mock('middleware/AppContext', () => ({
+ useAppContext: () => ({
+ features: { snapshots: { accessible: true } },
+ rbac: { repoWrite: true, repoRead: true },
+ }),
+}));
+
+it('Render delete modal where snapshot is not included in any templates', () => {
+ (useGetSnapshotList as jest.Mock).mockImplementation(() => ({
+ data: {
+ isLoading: false,
+ data: [defaultSnapshotItem],
+ },
+ }));
+ (useFetchTemplatesForSnapshots as jest.Mock).mockImplementation(() => ({
+ isError: false,
+ data: {
+ data: [],
+ meta: { limit: 10, offset: 0, count: 0 },
+ isLoading: false,
+ },
+ }));
+
+ const { queryByText } = render(
+
+
+ ,
+ );
+
+ expect(
+ queryByText(formatDateDDMMMYYYY(defaultSnapshotItem.created_at, true)),
+ ).toBeInTheDocument();
+ expect(queryByText(defaultTemplateItem.name)).not.toBeInTheDocument();
+ expect(queryByText(defaultTemplateItem2.name)).not.toBeInTheDocument();
+ expect(queryByText('None')).toBeInTheDocument();
+});
+
+it('Render delete modal where snapshot is included in templates', () => {
+ (useGetSnapshotList as jest.Mock).mockImplementation(() => ({
+ data: {
+ isLoading: false,
+ data: [defaultSnapshotItem],
+ },
+ }));
+ (useFetchTemplatesForSnapshots as jest.Mock).mockImplementation(() => ({
+ isError: false,
+ data: {
+ data: [defaultTemplateItem, defaultTemplateItem2],
+ meta: { limit: 10, offset: 0, count: 2 },
+ isLoading: false,
+ },
+ }));
+
+ const { queryByText } = render(
+
+
+ ,
+ );
+
+ expect(
+ queryByText(formatDateDDMMMYYYY(defaultSnapshotItem.created_at, true)),
+ ).toBeInTheDocument();
+ expect(queryByText(defaultTemplateItem.name)).toBeInTheDocument();
+ expect(queryByText(defaultTemplateItem2.name)).toBeInTheDocument();
+ expect(queryByText('Some snapshots have associated templates.')).toBeInTheDocument();
+ expect(queryByText('None')).not.toBeInTheDocument();
+});
diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/DeleteSnapshotsModal/DeleteSnapshotsModal.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/DeleteSnapshotsModal/DeleteSnapshotsModal.tsx
new file mode 100644
index 00000000..9a3e74d8
--- /dev/null
+++ b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/DeleteSnapshotsModal/DeleteSnapshotsModal.tsx
@@ -0,0 +1,345 @@
+import {
+ Alert,
+ Bullseye,
+ Button,
+ ExpandableSection,
+ ExpandableSectionToggle,
+ List,
+ ListItem,
+ Modal,
+ ModalVariant,
+ Spinner,
+ Stack,
+ StackItem,
+ Text,
+} from '@patternfly/react-core';
+import { Table, Tbody, Td, Th, Thead, Tr } from '@patternfly/react-table';
+
+import { global_Color_100 } from '@patternfly/react-tokens';
+import { useEffect, useState } from 'react';
+import { createUseStyles } from 'react-jss';
+import Hide from 'components/Hide/Hide';
+import { useQueryClient } from 'react-query';
+import { useHref, useLocation, useNavigate, useParams } from 'react-router-dom';
+import { useSnapshotListOutletContext } from '../SnapshotListModal';
+import useRootPath from 'Hooks/useRootPath';
+import { REPOSITORIES_ROUTE, TEMPLATES_ROUTE } from 'Routes/constants';
+import { SnapshotItem } from 'services/Content/ContentApi';
+import { useBulkDeleteSnapshotsMutate, useGetSnapshotList } from 'services/Content/ContentQueries';
+import { TemplateItem } from 'services/Templates/TemplateApi';
+import { useFetchTemplatesForSnapshots } from 'services/Templates/TemplateQueries';
+import { formatDateDDMMMYYYY } from 'helpers';
+import ChangedArrows from '../components/ChangedArrows';
+import { SnapshotDetailTab } from '../../SnapshotDetailsModal/SnapshotDetailsModal';
+
+const useStyles = createUseStyles({
+ description: {
+ paddingTop: '12px', // 4px on the title bottom padding makes this the "standard" 16 total padding
+ color: global_Color_100.value,
+ },
+ removeButton: {
+ marginRight: '36px',
+ transition: 'unset!important',
+ },
+ link: {
+ padding: 0,
+ },
+ templateColumnMinWidth: {
+ minWidth: '200px!important',
+ },
+ expandableSectionMargin: {
+ marginTop: '8px',
+ },
+});
+
+export default function DeleteSnapshotsModal() {
+ const classes = useStyles();
+ const navigate = useNavigate();
+ const queryClient = useQueryClient();
+ const rootPath = useRootPath();
+ const path = useHref('content');
+ const pathname = path.split('content')[0] + 'content';
+ const { repoUUID: uuid = '' } = useParams();
+ const { search } = useLocation();
+ const [isLoading, setIsLoading] = useState(true);
+ const [affectsTemplates, setAffectsTemplates] = useState(false);
+ const [expandState, setExpandState] = useState({});
+ const maxTemplatesToShow = 3;
+
+ const {
+ clearCheckedSnapshots,
+ deletionContext: { checkedSnapshots },
+ } = useSnapshotListOutletContext();
+
+ const snapshotUUIDFromPath = new URLSearchParams(search).get('snapshotUUID')?.split(',');
+ const checkedSnapshotsArray = checkedSnapshots ? Array.from(checkedSnapshots) : [];
+ const uuids = snapshotUUIDFromPath?.length
+ ? snapshotUUIDFromPath
+ : checkedSnapshotsArray.length
+ ? checkedSnapshotsArray
+ : [];
+ const snapshotsToDelete = new Set(uuids);
+
+ const { mutateAsync: deleteSnapshots, isLoading: isDeletingSnapshots } =
+ useBulkDeleteSnapshotsMutate(queryClient, uuid, snapshotsToDelete);
+
+ const onClose = () => navigate(`${rootPath}/${REPOSITORIES_ROUTE}/${uuid}/snapshots`);
+
+ const onSave = async () => {
+ deleteSnapshots(snapshotsToDelete).then(() => {
+ onClose();
+ clearCheckedSnapshots();
+ });
+ };
+
+ const {
+ isError: isSnapshotError,
+ data: snapshots = { data: [], meta: { count: 0, limit: -1, offset: 0 } },
+ } = useGetSnapshotList(uuid, 1, -1, '');
+
+ const { isError: isTemplateError, data: templates } = useFetchTemplatesForSnapshots(uuid, uuids);
+
+ useEffect(() => {
+ if (snapshots && templates) {
+ if (templates.data.length) {
+ setAffectsTemplates(true);
+ }
+ setIsLoading(false);
+ }
+ if (isSnapshotError || isTemplateError) {
+ onClose();
+ }
+ }, [isSnapshotError, snapshots.data, isTemplateError, templates]);
+
+ const actionTakingPlace = isDeletingSnapshots || isLoading;
+
+ const columnHeaders = ['Snapshots', 'Change', 'Packages', 'Errata', 'Associated Templates'];
+
+ return (
+
+
+
+ Removing these snapshots will change content in their associated templates. Templates
+ will switch to a snapshot taken closest to the deleted one.
+
+
+
+ Are you sure you want to remove these snapshots?
+
+ >
+ }
+ isOpen
+ onClose={onClose}
+ footer={
+
+
+
+
+
+
+ }
+ >
+
+
+
+
+
+
+
+
+
+ {columnHeaders.map((columnHeader) => (
+
+ {columnHeader}
+ |
+ ))}
+
+
+
+ {snapshots.data
+ ?.filter((snap) => snapshotsToDelete.has(snap.uuid))
+ .map(
+ (
+ {
+ uuid: snap_uuid,
+ created_at,
+ added_counts,
+ removed_counts,
+ content_counts,
+ }: SnapshotItem,
+ index,
+ ) => {
+ const templatesForSnapshot =
+ templates?.data.filter(
+ (t) => t.snapshots.filter((s) => s.uuid == snap_uuid).length > 0,
+ ) ?? [];
+ return (
+
+ {formatDateDDMMMYYYY(created_at, true)} |
+
+
+ |
+
+
+ |
+
+
+ |
+
+ {templatesForSnapshot.length ? (
+
+ {templatesForSnapshot
+ .slice(0, maxTemplatesToShow)
+ .map((template: TemplateItem, index) => (
+
+
+
+ ))}
+
+
+ {templatesForSnapshot
+ .slice(maxTemplatesToShow, templatesForSnapshot.length)
+ .map((template: TemplateItem, index) => (
+
+
+
+ ))}
+
+
+ setExpandState((prev) => ({ ...prev, [index]: !prev[index] }))
+ }
+ toggleId={
+ expandState[index]
+ ? 'detached-expandable-section-toggle-open'
+ : 'detached-expandable-section-toggle'
+ }
+ contentId={
+ expandState[index]
+ ? 'detached-expandable-section-content-open'
+ : 'detached-expandable-section-content'
+ }
+ direction='up'
+ >
+ {expandState[index]
+ ? 'Show less'
+ : `and ${templatesForSnapshot.length - maxTemplatesToShow} more`}
+
+
+
+ ) : (
+ 'None'
+ )}
+ |
+
+ );
+ },
+ )}
+
+
+
+
+ );
+}
diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.test.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.test.tsx
index 3d420ff8..6aa3919a 100644
--- a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.test.tsx
+++ b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.test.tsx
@@ -14,11 +14,12 @@ jest.mock('services/Content/ContentQueries', () => ({
useFetchContent: jest.fn(),
useGetSnapshotList: jest.fn(),
useGetRepoConfigFileQuery: () => ({ mutateAsync: jest.fn() }),
- useGetLatestRepoConfigFileQuery: () => ({ mutateAsync: jest.fn() })
+ useGetLatestRepoConfigFileQuery: () => ({ mutateAsync: jest.fn() }),
}));
jest.mock('react-router-dom', () => ({
useNavigate: jest.fn(),
+ Outlet: () => <>>,
useParams: () => ({
repoUUID: 'some-uuid',
}),
diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.tsx
index ea136e60..dccca0f8 100644
--- a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.tsx
+++ b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.tsx
@@ -9,6 +9,8 @@ import {
PaginationVariant,
} from '@patternfly/react-core';
import {
+ ActionsColumn,
+ IAction,
InnerScrollContainer,
Table /* data-codemods */,
TableVariant,
@@ -19,24 +21,22 @@ import {
ThProps,
Tr,
} from '@patternfly/react-table';
-import {
- global_BackgroundColor_100,
- global_Color_200
-} from '@patternfly/react-tokens';
-import { useEffect, useMemo, useState } from 'react';
+import { global_BackgroundColor_100, global_Color_200 } from '@patternfly/react-tokens';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { createUseStyles } from 'react-jss';
import { SkeletonTable } from '@patternfly/react-component-groups';
import Hide from 'components/Hide/Hide';
import { ContentOrigin, SnapshotItem } from 'services/Content/ContentApi';
import { useFetchContent, useGetSnapshotList } from 'services/Content/ContentQueries';
-import { useNavigate, useParams } from 'react-router-dom';
+import { Outlet, useNavigate, useOutletContext, useParams } from 'react-router-dom';
import useRootPath from 'Hooks/useRootPath';
import ChangedArrows from './components/ChangedArrows';
import { useAppContext } from 'middleware/AppContext';
import RepoConfig from './components/RepoConfig';
-import { REPOSITORIES_ROUTE } from 'Routes/constants';
+import { REPOSITORIES_ROUTE, DELETE_ROUTE } from 'Routes/constants';
import { SnapshotDetailTab } from '../SnapshotDetailsModal/SnapshotDetailsModal';
import { formatDateDDMMMYYYY } from 'helpers';
+import ConditionalTooltip from 'components/ConditionalTooltip/ConditionalTooltip';
import LatestRepoConfig from './components/LatestRepoConfig';
const useStyles = createUseStyles({
@@ -60,21 +60,25 @@ const useStyles = createUseStyles({
justifyContent: 'space-between',
minHeight: '68px',
},
+ checkboxMinWidth: {
+ minWidth: '45px!important',
+ },
});
const perPageKey = 'snapshotPerPage';
-export default function SnapshotListModal() {
+const SnapshotListModal = () => {
const classes = useStyles();
const rootPath = useRootPath();
const { repoUUID: uuid = '' } = useParams();
- const { contentOrigin } = useAppContext();
+ const { contentOrigin, rbac } = useAppContext();
const navigate = useNavigate();
const storedPerPage = Number(localStorage.getItem(perPageKey)) || 20;
const [page, setPage] = useState(1);
const [perPage, setPerPage] = useState(storedPerPage);
const [activeSortIndex, setActiveSortIndex] = useState(0);
const [activeSortDirection, setActiveSortDirection] = useState<'asc' | 'desc'>('desc');
+ const [checkedSnapshots, setCheckedSnapshots] = useState>(new Set());
const columnHeaders = ['Snapshots', 'Change', 'Packages', 'Errata', 'Config'];
@@ -135,6 +139,46 @@ export default function SnapshotListModal() {
(contentOrigin === ContentOrigin.REDHAT ? `?origin=${contentOrigin}` : ''),
);
+ const onSelectSnapshot = (uuid: string, value: boolean) => {
+ const newSet = new Set(checkedSnapshots);
+ if (value) {
+ newSet.add(uuid);
+ } else {
+ newSet.delete(uuid);
+ }
+ setCheckedSnapshots(newSet);
+ };
+
+ const clearCheckedSnapshots = () => setCheckedSnapshots(new Set());
+
+ const selectAllSnapshots = (_, checked: boolean) => {
+ if (checked) {
+ const newSet = new Set(checkedSnapshots);
+ data.data.forEach((snapshot) => newSet.add(snapshot.uuid));
+ setCheckedSnapshots(newSet);
+ } else {
+ const newSet = new Set(checkedSnapshots);
+ for (const snapshot of data.data) {
+ newSet.delete(snapshot.uuid);
+ }
+ setCheckedSnapshots(newSet);
+ }
+ };
+
+ const atLeastOneRepoChecked = useMemo(() => checkedSnapshots.size >= 1, [checkedSnapshots]);
+
+ const areAllSnapshotsSelected = useMemo(() => {
+ let atLeastOneSelectedOnPage = false;
+ const allSelectedOrPending = data.data.every((snapshot) => {
+ if (checkedSnapshots.has(snapshot.uuid)) {
+ atLeastOneSelectedOnPage = true;
+ }
+ return checkedSnapshots.has(snapshot.uuid);
+ });
+ // Returns false if all repos on current page are pending (none selected)
+ return allSelectedOrPending && atLeastOneSelectedOnPage;
+ }, [data, checkedSnapshots]);
+
const {
data: snapshotsList = [],
meta: { count = 0 },
@@ -144,157 +188,262 @@ export default function SnapshotListModal() {
const loadingOrZeroCount = fetchingOrLoading || !count;
+ const isRedHatRepository = contentOrigin === ContentOrigin.REDHAT;
+
+ const rowActions = useCallback(
+ (snap_uuid: string): IAction[] =>
+ isRedHatRepository
+ ? []
+ : [
+ {
+ isDisabled: count < 2,
+ title: 'Delete',
+ onClick: () => navigate(`${DELETE_ROUTE}?snapshotUUID=${snap_uuid}`),
+ },
+ ],
+ [isRedHatRepository, data, count],
+ );
+
return (
-
- View list of snapshots for{' '}
- {contentData?.name ? {contentData?.name} : 'a repository'}.
- {/* You may select snapshots to delete them.
-
- You may also view the snapshot comparisons here. */}
-
- }
- isOpen
- onClose={onClose}
- footer={
-
- }
- >
-
-
-
+ <>
+
+
+
+
+ View list of snapshots for{' '}
+ {contentData?.name ? {contentData?.name} : 'a repository'}.
+ {/* You may select snapshots to delete them.
+
+ You may also view the snapshot comparisons here. */}
+
+ }
+ isOpen
+ onClose={onClose}
+ footer={
+
+ }
+ >
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
-
-
-
- {columnHeaders.map((columnHeader, index) => (
-
- {columnHeader}
- |
- ))}
-
-
-
-
- {snapshotsList.map(
- (
- {
- uuid: snap_uuid,
- created_at,
- content_counts,
- added_counts,
- removed_counts,
- }: SnapshotItem,
- index: number,
- ) => (
-
- {formatDateDDMMMYYYY(created_at, true)} |
-
-
+
+
+
+
+
+
+
+
+
+
+ |
-
-
-
- |
-
- |
-
-
- |
+ {columnHeader}
+
+ ))}
- ),
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ {snapshotsList.map(
+ (
+ {
+ uuid: snap_uuid,
+ created_at,
+ content_counts,
+ added_counts,
+ removed_counts,
+ }: SnapshotItem,
+ index: number,
+ ) => (
+
+
+
+ onSelectSnapshot(snap_uuid, isSelecting),
+ isSelected: checkedSnapshots.has(snap_uuid),
+ }}
+ />
+
+ | {formatDateDDMMMYYYY(created_at, true)} |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+ |
+
+
+ ),
+ )}
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+ >
);
-}
+};
+
+export const useSnapshotListOutletContext = () =>
+ useOutletContext<{
+ clearCheckedSnapshots: () => void;
+ deletionContext: {
+ checkedSnapshots: Set;
+ };
+ }>();
+
+export default SnapshotListModal;
diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/components/LatestRepoConfig.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/components/LatestRepoConfig.tsx
index dedadc03..f137f30c 100644
--- a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/components/LatestRepoConfig.tsx
+++ b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/components/LatestRepoConfig.tsx
@@ -1,21 +1,21 @@
-import {Flex, FlexItem, Text, TextContent, TextVariants} from '@patternfly/react-core';
+import { Flex, FlexItem, Text, TextContent, TextVariants } from '@patternfly/react-core';
import RepoConfig from './RepoConfig';
interface Props {
- repoUUID: string;
+ repoUUID: string;
}
const LatestRepoConfig = ({ repoUUID }: Props) => (
-
-
-
- Latest Snapshot Config:
-
-
-
-
-
-
- );
+
+
+
+ Latest Snapshot Config:
+
+
+
+
+
+
+);
export default LatestRepoConfig;
diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/components/RepoConfig.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/components/RepoConfig.tsx
index 46c79849..2e6a825e 100644
--- a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/components/RepoConfig.tsx
+++ b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/components/RepoConfig.tsx
@@ -2,7 +2,10 @@ import { Button, Flex, FlexItem, Icon } from '@patternfly/react-core';
import { createUseStyles } from 'react-jss';
import { global_disabled_color_100 } from '@patternfly/react-tokens';
-import { useGetRepoConfigFileQuery, useGetLatestRepoConfigFileQuery } from 'services/Content/ContentQueries';
+import {
+ useGetRepoConfigFileQuery,
+ useGetLatestRepoConfigFileQuery,
+} from 'services/Content/ContentQueries';
import { CopyIcon, DownloadIcon } from '@patternfly/react-icons';
@@ -25,7 +28,9 @@ interface Props {
const RepoConfig = ({ repoUUID, snapUUID, latest }: Props) => {
const classes = useStyles();
- const { mutateAsync } = latest ? useGetLatestRepoConfigFileQuery(repoUUID) : useGetRepoConfigFileQuery(repoUUID, snapUUID);
+ const { mutateAsync } = latest
+ ? useGetLatestRepoConfigFileQuery(repoUUID)
+ : useGetRepoConfigFileQuery(repoUUID, snapUUID);
const copyConfigFile = async () => {
const data = await mutateAsync();
diff --git a/src/Pages/Templates/TemplatesTable/TemplatesTable.tsx b/src/Pages/Templates/TemplatesTable/TemplatesTable.tsx
index c41ae621..2e747188 100644
--- a/src/Pages/Templates/TemplatesTable/TemplatesTable.tsx
+++ b/src/Pages/Templates/TemplatesTable/TemplatesTable.tsx
@@ -110,6 +110,7 @@ const TemplatesTable = () => {
version: '',
search: '',
repository_uuids: '',
+ snapshot_uuids: '',
};
const [filterData, setFilterData] = useState(defaultValues);
diff --git a/src/Pages/Templates/TemplatesTable/components/TemplateFilters.test.tsx b/src/Pages/Templates/TemplatesTable/components/TemplateFilters.test.tsx
index 41dadabb..050aecee 100644
--- a/src/Pages/Templates/TemplatesTable/components/TemplateFilters.test.tsx
+++ b/src/Pages/Templates/TemplatesTable/components/TemplateFilters.test.tsx
@@ -29,6 +29,7 @@ it('Render loading state (disabled)', async () => {
version: '',
arch: '',
repository_uuids: '',
+ snapshot_uuids: '',
}}
/>,
);
@@ -46,6 +47,7 @@ it('Select a filter of each type and ensure chips are present', async () => {
version: '',
arch: '',
repository_uuids: '',
+ snapshot_uuids: '',
}}
/>,
);
diff --git a/src/Pages/Templates/TemplatesTable/components/TemplateFilters.tsx b/src/Pages/Templates/TemplatesTable/components/TemplateFilters.tsx
index 425e262f..904869ed 100644
--- a/src/Pages/Templates/TemplatesTable/components/TemplateFilters.tsx
+++ b/src/Pages/Templates/TemplatesTable/components/TemplateFilters.tsx
@@ -73,7 +73,7 @@ const Filters = ({ isLoading, setFilterData, filterData }: Props) => {
setSearchQuery('');
setSelectedVersion('');
setSelectedArch('');
- setFilterData({ search: '', version: '', arch: '', repository_uuids: '' });
+ setFilterData({ search: '', version: '', arch: '', repository_uuids: '', snapshot_uuids: '' });
};
useEffect(() => {
@@ -108,6 +108,7 @@ const Filters = ({ isLoading, setFilterData, filterData }: Props) => {
version: getLabels('version', debouncedSelectedVersion),
arch: getLabels('arch', debouncedSelectedArch),
repository_uuids: '',
+ snapshot_uuids: '',
});
}, [debouncedSearchQuery, debouncedSelectedVersion, debouncedSelectedArch]);
diff --git a/src/Routes/index.tsx b/src/Routes/index.tsx
index 3fcd89ef..c8b848b4 100644
--- a/src/Routes/index.tsx
+++ b/src/Routes/index.tsx
@@ -41,6 +41,7 @@ import ViewPayloadModal from 'Pages/Repositories/AdminTaskTable/components/ViewP
import DeleteTemplateModal from 'Pages/Templates/TemplatesTable/components/DeleteTemplateModal';
import TemplateRepositoriesTab from 'Pages/Templates/TemplateDetails/components/Tabs/TemplateRepositoriesTab';
import UploadContent from 'Pages/Repositories/ContentListTable/components/UploadContent/UploadContent';
+import DeleteSnapshotsModal from 'Pages/Repositories/ContentListTable/components/SnapshotListModal/DeleteSnapshotsModal/DeleteSnapshotsModal';
export default function RepositoriesRoutes() {
const key = useMemo(() => Math.random(), []);
@@ -75,7 +76,17 @@ export default function RepositoriesRoutes() {
key={`:repoUUID/${SNAPSHOTS_ROUTE}`}
path={`:repoUUID/${SNAPSHOTS_ROUTE}`}
element={}
- />
+ >
+ {rbac?.repoWrite ? (
+ }
+ />
+ ) : (
+ ''
+ )}
+
Promise = async
return data;
};
+export const deleteSnapshots: (repoUuid: string, uuids: string[]) => Promise = async (
+ repoUuid: string,
+ uuids: string[],
+) => {
+ const { data } = await axios.post(
+ `/api/content-sources/v1/repositories/${repoUuid}/snapshots/bulk_delete/`,
+ { uuids },
+ );
+ return data;
+};
+
export const getSnapshotsByDate = async (
uuids: string[],
date: string,
@@ -477,12 +488,8 @@ export const getRepoConfigFile: (snapshot_uuid: string) => Promise = asy
return data;
};
-export const getLatestRepoConfigFile: (repoUUID: string) => Promise = async (
- repoUUID,
-) => {
- const { data } = await axios.get(
- `/api/content-sources/v1/repositories/${repoUUID}/config.repo`,
- );
+export const getLatestRepoConfigFile: (repoUUID: string) => Promise = async (repoUUID) => {
+ const { data } = await axios.get(`/api/content-sources/v1/repositories/${repoUUID}/config.repo`);
return data;
};
diff --git a/src/services/Content/ContentQueries.ts b/src/services/Content/ContentQueries.ts
index 59e963ec..1cdaa1ca 100644
--- a/src/services/Content/ContentQueries.ts
+++ b/src/services/Content/ContentQueries.ts
@@ -39,11 +39,20 @@ import {
type EditContentRequestItem,
type ValidateContentRequestItem,
addUploads,
- type AddUploadRequest, getLatestRepoConfigFile,
+ type AddUploadRequest,
+ deleteSnapshots,
+ getLatestRepoConfigFile,
} from './ContentApi';
import { ADMIN_TASK_LIST_KEY } from '../AdminTasks/AdminTaskQueries';
import useErrorNotification from 'Hooks/useErrorNotification';
import useNotification from 'Hooks/useNotification';
+import {
+ GET_TEMPLATES_KEY,
+ GET_TEMPLATE_PACKAGES_KEY,
+ TEMPLATE_ERRATA_KEY,
+ TEMPLATE_SNAPSHOTS_KEY,
+ TEMPLATES_FOR_SNAPSHOTS,
+} from '../Templates/TemplateQueries';
export const CONTENT_LIST_KEY = 'CONTENT_LIST_KEY';
export const POPULAR_REPOSITORIES_LIST_KEY = 'POPULAR_REPOSITORIES_LIST_KEY';
@@ -758,7 +767,6 @@ export const useIntrospectRepositoryMutate = (
});
}
queryClient.invalidateQueries(ADMIN_TASK_LIST_KEY);
- queryClient.invalidateQueries(CONTENT_LIST_KEY);
},
// If the mutation fails, use the context returned from onMutate to roll back
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -801,18 +809,87 @@ export const useGetRepoConfigFileQuery = (repo_uuid: string, snapshot_uuid: stri
export const useGetLatestRepoConfigFileQuery = (repo_uuid: string) => {
const errorNotifier = useErrorNotification();
return useMutation(
- [LATEST_REPO_CONFIG_FILE_KEY, repo_uuid],
- async () => await getLatestRepoConfigFile(repo_uuid),
- {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- onError: (err: any) => {
- errorNotifier(
- 'Unable to find config.repo with the given UUID.',
- 'An error occurred',
- err,
- 'repo-config-error',
- );
- },
+ [LATEST_REPO_CONFIG_FILE_KEY, repo_uuid],
+ async () => await getLatestRepoConfigFile(repo_uuid),
+ {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ onError: (err: any) => {
+ errorNotifier(
+ 'Unable to find config.repo with the given UUID.',
+ 'An error occurred',
+ err,
+ 'repo-config-error',
+ );
},
+ },
);
};
+
+export const useBulkDeleteSnapshotsMutate = (
+ queryClient: QueryClient,
+ repoUuid: string,
+ selected: Set,
+) => {
+ const uuids = Array.from(selected);
+ const snapshotListKeyArray = [LIST_SNAPSHOTS_KEY, repoUuid];
+ const errorNotifier = useErrorNotification();
+
+ return useMutation(() => deleteSnapshots(repoUuid, uuids), {
+ onMutate: async (checkedSnapshots: Set) => {
+ await queryClient.cancelQueries(snapshotListKeyArray);
+ const previousData: Partial =
+ queryClient.getQueryData(snapshotListKeyArray) || {};
+
+ const newMeta = previousData.meta
+ ? {
+ ...previousData.meta,
+ count: previousData.meta.count ? previousData.meta.count - checkedSnapshots.size : 1,
+ }
+ : undefined;
+
+ queryClient.setQueryData(snapshotListKeyArray, () => ({
+ ...previousData,
+ data: previousData.data?.filter((data) => !checkedSnapshots.has(data.uuid)),
+ meta: newMeta,
+ }));
+ return { previousData, newMeta, queryClient };
+ },
+ onSuccess: (_data, _variables, context) => {
+ const { newMeta } = context as {
+ newMeta: Meta;
+ };
+ queryClient.setQueriesData(LIST_SNAPSHOTS_KEY, (data: Partial = {}) => {
+ if (data?.meta?.count) {
+ data.meta.count = newMeta?.count;
+ }
+ return data;
+ });
+ queryClient.invalidateQueries(CONTENT_LIST_KEY);
+ queryClient.invalidateQueries(GET_TEMPLATES_KEY);
+ queryClient.invalidateQueries(ADMIN_TASK_LIST_KEY);
+ queryClient.invalidateQueries(TEMPLATE_SNAPSHOTS_KEY);
+ queryClient.invalidateQueries(TEMPLATES_FOR_SNAPSHOTS);
+ queryClient.invalidateQueries(TEMPLATE_ERRATA_KEY);
+ queryClient.invalidateQueries(GET_TEMPLATE_PACKAGES_KEY);
+ queryClient.invalidateQueries(LIST_SNAPSHOTS_KEY);
+ queryClient.invalidateQueries(SNAPSHOT_ERRATA_KEY);
+ queryClient.invalidateQueries(SNAPSHOT_PACKAGES_KEY);
+ queryClient.invalidateQueries(REPO_CONFIG_FILE_KEY);
+ queryClient.invalidateQueries(LATEST_REPO_CONFIG_FILE_KEY);
+ },
+ onError: (err: { response?: { data: ErrorResponse } }, _newData, context) => {
+ if (context) {
+ const { previousData } = context as {
+ previousData: SnapshotListResponse;
+ };
+ queryClient.setQueryData(snapshotListKeyArray, previousData);
+ }
+ errorNotifier(
+ 'Error deleting snapshot from the snapshot list',
+ 'An error occured',
+ err,
+ 'bulk-delete-error',
+ );
+ },
+ });
+};
diff --git a/src/services/Templates/TemplateApi.ts b/src/services/Templates/TemplateApi.ts
index 68108bf5..7796005c 100644
--- a/src/services/Templates/TemplateApi.ts
+++ b/src/services/Templates/TemplateApi.ts
@@ -5,6 +5,7 @@ import {
type ErrataResponse,
type PackageItem,
SnapshotListResponse,
+ SnapshotItem,
} from '../Content/ContentApi';
import { objectToUrlParams } from 'helpers';
import { AdminTask } from 'services/AdminTasks/AdminTaskApi';
@@ -29,6 +30,7 @@ export interface TemplateItem {
org_id: string;
description: string;
repository_uuids: string[];
+ snapshots: SnapshotItem[];
arch: string;
version: string;
date: string;
@@ -60,6 +62,7 @@ export type TemplateFilterData = {
version: string;
search: string;
repository_uuids: string;
+ snapshot_uuids: string;
};
export const getTemplates: (
@@ -71,7 +74,7 @@ export const getTemplates: (
page,
limit,
sortBy,
- { search, arch, version, repository_uuids },
+ { search, arch, version, repository_uuids, snapshot_uuids },
) => {
const { data } = await axios.get(
`/api/content-sources/v1/templates/?${objectToUrlParams({
@@ -82,6 +85,7 @@ export const getTemplates: (
version,
sort_by: sortBy,
repository_uuids: repository_uuids,
+ snapshot_uuids: snapshot_uuids,
})}`,
);
return data;
@@ -158,6 +162,19 @@ export const getTemplateSnapshots: (
return data;
};
+export const getTemplatesForSnapshots: (
+ snapshotUuids: string[],
+) => Promise = async (snapshotUuids: string[]) => {
+ const { data } = await axios.get(
+ `/api/content-sources/v1/templates/?${objectToUrlParams({
+ offset: '0',
+ limit: '-1',
+ snapshot_uuids: snapshotUuids.join(','),
+ })}`,
+ );
+ return data;
+};
+
export const EditTemplate: (request: EditTemplateRequest) => Promise = async (request) => {
const { data } = await axios.put(`/api/content-sources/v1.0/templates/${request.uuid}`, request);
return data;
diff --git a/src/services/Templates/TemplateQueries.ts b/src/services/Templates/TemplateQueries.ts
index 12cdf5b1..078b6b6a 100644
--- a/src/services/Templates/TemplateQueries.ts
+++ b/src/services/Templates/TemplateQueries.ts
@@ -16,6 +16,7 @@ import {
getTemplatePackages,
getTemplateErrata,
getTemplateSnapshots,
+ getTemplatesForSnapshots,
} from './TemplateApi';
import useNotification from 'Hooks/useNotification';
import { AlertVariant } from '@patternfly/react-core';
@@ -26,6 +27,7 @@ export const GET_TEMPLATES_KEY = 'GET_TEMPLATES_KEY';
export const GET_TEMPLATE_PACKAGES_KEY = 'GET_TEMPLATE_PACKAGES_KEY';
export const TEMPLATE_ERRATA_KEY = 'TEMPLATE_ERRATA_KEY';
export const TEMPLATE_SNAPSHOTS_KEY = 'TEMPLATE_SNAPSHOTS_KEY';
+export const TEMPLATES_FOR_SNAPSHOTS = 'TEMPLATES_BY_SNAPSHOTS_KEY';
const TEMPLATE_LIST_POLLING_TIME = 15000; // 15 seconds
const TEMPLATE_FETCH_POLLING_TIME = 5000; // 5 seconds
@@ -45,6 +47,7 @@ export const useEditTemplateQuery = (queryClient: QueryClient, request: EditTemp
queryClient.invalidateQueries(FETCH_TEMPLATE_KEY);
queryClient.invalidateQueries(GET_TEMPLATE_PACKAGES_KEY);
queryClient.invalidateQueries(TEMPLATE_ERRATA_KEY);
+ queryClient.invalidateQueries(TEMPLATES_FOR_SNAPSHOTS);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onError: (err: any) => {
@@ -160,6 +163,26 @@ export const useFetchTemplateSnapshotsQuery = (
);
};
+export const useFetchTemplatesForSnapshots = (repoUuid: string, snapshotUuids: string[]) => {
+ const errorNotifier = useErrorNotification();
+ return useQuery(
+ [TEMPLATES_FOR_SNAPSHOTS, repoUuid, ...snapshotUuids],
+ () => getTemplatesForSnapshots(snapshotUuids),
+ {
+ onError: (err) => {
+ errorNotifier(
+ 'Unable to find templates for the given snapshots.',
+ 'An error occurred',
+ err,
+ 'template-for-snapshots-error',
+ );
+ },
+ keepPreviousData: true,
+ staleTime: 20000,
+ },
+ );
+};
+
export const useTemplateList = (
page: number,
limit: number,
diff --git a/src/testingHelpers.tsx b/src/testingHelpers.tsx
index 80a83631..8c96f36b 100644
--- a/src/testingHelpers.tsx
+++ b/src/testingHelpers.tsx
@@ -293,6 +293,7 @@ export const defaultTemplateItem: TemplateItem = {
'28b8d2b1-e4d6-4d8a-be12-1104601fb96e',
'053603c7-6ef0-4abe-8542-feacb8f7d575',
],
+ snapshots: [defaultSnapshotItem],
use_latest: false,
created_at: '2024-01-22T00:00:00-07:00',
updated_at: '2024-01-22T00:00:00-07:00',
@@ -317,6 +318,7 @@ export const defaultTemplateItem2: TemplateItem = {
'31c06bb4-ef1b-42f5-8c91-0ff67e7d8a1b',
'28b8d2b1-e4d6-4d8a-be12-1104601fb96e',
],
+ snapshots: [defaultSnapshotItem],
use_latest: false,
created_at: '2024-01-22T00:00:00-07:00',
updated_at: '2024-01-22T00:00:00-07:00',