From 186335f81a5ef1ae9b4e489845ee09e0f730b1f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominik=20V=C3=A1gner?= <55958556+dominikvagner@users.noreply.github.com> Date: Fri, 29 Nov 2024 21:21:43 +0100 Subject: [PATCH] Fixes 4743: add delete and bulk delete of snapshots (#373) * Fixes 4743: add delete and bulk delete of snapshots * fix: optimization with promise all, add new tests * fix: improve templates for snapshots query * fix+style: small fixes --- .../components/AddContent/AddContent.test.tsx | 3 +- .../components/AddContent/AddContent.tsx | 6 +- .../DeleteContentModal/DeleteContentModal.tsx | 1 + .../DeleteSnapshotsModal.test.tsx | 115 +++++ .../DeleteSnapshotsModal.tsx | 345 ++++++++++++++ .../SnapshotListModal.test.tsx | 3 +- .../SnapshotListModal/SnapshotListModal.tsx | 445 ++++++++++++------ .../components/LatestRepoConfig.tsx | 26 +- .../components/RepoConfig.tsx | 9 +- .../TemplatesTable/TemplatesTable.tsx | 1 + .../components/TemplateFilters.test.tsx | 2 + .../components/TemplateFilters.tsx | 3 +- src/Routes/index.tsx | 13 +- src/services/Content/ContentApi.ts | 19 +- src/services/Content/ContentQueries.ts | 105 ++++- src/services/Templates/TemplateApi.ts | 19 +- src/services/Templates/TemplateQueries.ts | 23 + src/testingHelpers.tsx | 2 + 18 files changed, 951 insertions(+), 189 deletions(-) create mode 100644 src/Pages/Repositories/ContentListTable/components/SnapshotListModal/DeleteSnapshotsModal/DeleteSnapshotsModal.test.tsx create mode 100644 src/Pages/Repositories/ContentListTable/components/SnapshotListModal/DeleteSnapshotsModal/DeleteSnapshotsModal.tsx 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) => ( + + ))} + + + + {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 ( + + + + + + + + ); + }, + )} + +
+ {columnHeader} +
{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) => ( - - ))} - - - - - {snapshotsList.map( - ( - { - uuid: snap_uuid, - created_at, - content_counts, - added_counts, - removed_counts, - }: SnapshotItem, - index: number, - ) => ( - - - + {snapshotsList.map( + ( + { + uuid: snap_uuid, + created_at, + content_counts, + added_counts, + removed_counts, + }: SnapshotItem, + index: number, + ) => ( + + + + + + + + + + + + ), + )} + +
- {columnHeader} -
{formatDateDDMMMYYYY(created_at, true)} - + + + + + + + + + + + - + {columnHeader} + + ))} - ), - )} - -
- - - - - - {content_counts?.['rpm.advisory'] || 0} - - - - -
-
- - - - - - - - - - - + + +
+ 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',