From 87fe83945d427e8727b1c3a711d7de66c07ded25 Mon Sep 17 00:00:00 2001 From: Dominik Vagner Date: Wed, 10 Jun 2026 17:08:14 +0200 Subject: [PATCH] feat(snapshots): add snapshot package changes tab Expose added, removed, and updated RPM diffs from snapshot detail so snapshot list changes can be inspected in place. Co-authored-by: Cursor --- .../SnapshotDetailsModal.test.tsx | 41 +- .../SnapshotDetailsModal.tsx | 33 +- .../SnapshotSelector.test.tsx | 85 +++- .../SnapshotDetailsModal/SnapshotSelector.tsx | 10 +- .../Tabs/SnapshotChangesTab.test.tsx | 132 +++++++ .../Tabs/SnapshotChangesTab.tsx | 364 ++++++++++++++++++ .../SnapshotListModal.test.tsx | 67 +++- .../SnapshotListModal/SnapshotListModal.tsx | 163 ++++---- src/services/Content/ContentApi.ts | 26 +- src/services/Content/ContentQueries.ts | 15 + src/testingHelpers.tsx | 16 + 11 files changed, 846 insertions(+), 106 deletions(-) create mode 100644 src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/Tabs/SnapshotChangesTab.test.tsx create mode 100644 src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/Tabs/SnapshotChangesTab.tsx diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotDetailsModal.test.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotDetailsModal.test.tsx index f149fb679..e0ea12dc2 100644 --- a/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotDetailsModal.test.tsx +++ b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotDetailsModal.test.tsx @@ -15,6 +15,10 @@ jest.mock('./Tabs/SnapshotErrataTab', () => ({ SnapshotErrataTab: () =>
Errata tab body
, })); +jest.mock('./Tabs/SnapshotChangesTab', () => ({ + SnapshotChangesTab: () =>
Changes tab body
, +})); + jest.mock('./SnapshotSelector', () => ({ SnapshotSelector: () =>
Snapshot selector
, })); @@ -95,13 +99,46 @@ describe('SnapshotDetailsModal', () => { }); }); + it('syncs changes tab from search params on mount', async () => { + window.history.replaceState({}, '', `/?tab=${SnapshotDetailTab.CHANGES}`); + + render(); + + await waitFor(() => { + expect( + screen.getByRole('tabpanel', { name: 'Snapshot changes detail tab' }), + ).toBeInTheDocument(); + }); + }); + it('updates search params when switching tabs', async () => { const user = userEvent.setup(); render(); - await user.click(screen.getByRole('tab', { name: 'Snapshot errata detail tab' })); + await user.click(screen.getByRole('tab', { name: 'Snapshot changes detail tab' })); + + expect(mockSetSearchParams).toHaveBeenCalledWith({ tab: SnapshotDetailTab.CHANGES }); + }); + + it('renders tabs in the expected order', () => { + render(); + + expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual([ + 'Packages', + 'Changes', + 'Advisories', + ]); + }); + + it('clears search params when switching back to packages', async () => { + const user = userEvent.setup(); + window.history.replaceState({}, '', `/?tab=${SnapshotDetailTab.CHANGES}`); + + render(); + + await user.click(screen.getByRole('tab', { name: 'Snapshot package detail tab' })); - expect(mockSetSearchParams).toHaveBeenCalledWith({ tab: SnapshotDetailTab.ERRATA }); + expect(mockSetSearchParams).toHaveBeenCalledWith({}); }); }); diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotDetailsModal.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotDetailsModal.tsx index 958ba2c92..77a3f7466 100644 --- a/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotDetailsModal.tsx +++ b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotDetailsModal.tsx @@ -21,6 +21,7 @@ import { createUseStyles } from 'react-jss'; import { SnapshotSelector } from './SnapshotSelector'; import { REPOSITORIES_ROUTE } from 'Routes/constants'; import { SnapshotErrataTab } from './Tabs/SnapshotErrataTab'; +import { SnapshotChangesTab } from './Tabs/SnapshotChangesTab'; import { modalTableSurfaceStyles } from 'helpers'; const useStyles = createUseStyles({ @@ -38,9 +39,13 @@ const useStyles = createUseStyles({ export enum SnapshotDetailTab { PACKAGES = 'packages', + CHANGES = 'changes', ERRATA = 'errata', } +const isSnapshotDetailTab = (value: string | null): value is SnapshotDetailTab => + Object.values(SnapshotDetailTab).includes(value as SnapshotDetailTab); + export default function SnapshotDetailsModal() { const { contentOrigin } = useAppContext(); const classes = useStyles(); @@ -48,20 +53,22 @@ export default function SnapshotDetailsModal() { const [urlSearchParams, setUrlSearchParams] = useSearchParams(); const rootPath = useRootPath(); const navigate = useNavigate(); - const [activeTabKey, setActiveTabKey] = useState(0); + const activeTab = urlSearchParams.get('tab'); + const [activeTabKey, setActiveTabKey] = useState(SnapshotDetailTab.PACKAGES); useEffect(() => { - if (urlSearchParams.get('tab') === SnapshotDetailTab.ERRATA) { - setActiveTabKey(1); - } - }, []); + setActiveTabKey(isSnapshotDetailTab(activeTab) ? activeTab : SnapshotDetailTab.PACKAGES); + }, [activeTab]); const handleTabClick = ( _: React.MouseEvent, tabIndex: string | number, ) => { - setUrlSearchParams(tabIndex ? { tab: SnapshotDetailTab.ERRATA } : {}); - setActiveTabKey(tabIndex); + const selectedTab = isSnapshotDetailTab(String(tabIndex)) + ? (tabIndex as SnapshotDetailTab) + : SnapshotDetailTab.PACKAGES; + setUrlSearchParams(selectedTab === SnapshotDetailTab.PACKAGES ? {} : { tab: selectedTab }); + setActiveTabKey(selectedTab); }; const onClose = () => @@ -100,7 +107,7 @@ export default function SnapshotDetailsModal() { aria-label='Snapshot detail tabs' > Packages} aria-label='Snapshot package detail tab' @@ -108,7 +115,15 @@ export default function SnapshotDetailsModal() { Changes} + aria-label='Snapshot changes detail tab' + > + + + Advisories} aria-label='Snapshot errata detail tab' diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotSelector.test.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotSelector.test.tsx index cb77557bc..714929a77 100644 --- a/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotSelector.test.tsx +++ b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotSelector.test.tsx @@ -1,8 +1,13 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { SnapshotSelector } from './SnapshotSelector'; import { useGetSnapshotList } from 'services/Content/ContentQueries'; import { defaultMetaItem, defaultSnapshotItem } from 'testingHelpers'; import { formatDateDDMMMYYYY } from 'helpers'; +import { SnapshotDetailTab } from './SnapshotDetailsModal'; + +const mockNavigate = jest.fn(); +const mockSearchParams = new URLSearchParams(); jest.mock('Hooks/useRootPath', () => () => 'someUrl'); @@ -11,26 +16,70 @@ jest.mock('services/Content/ContentQueries', () => ({ })); jest.mock('react-router-dom', () => ({ - useNavigate: jest.fn(), + useNavigate: () => mockNavigate, + useSearchParams: () => [mockSearchParams], useParams: () => ({ + repoUUID: 'repo-uuid', snapshotUUID: defaultSnapshotItem.uuid, }), })); -it('Render SnapshotSelector', () => { - (useGetSnapshotList as jest.Mock).mockImplementation(() => ({ - data: { - meta: defaultMetaItem, - data: [defaultSnapshotItem], - }, - isLoading: false, - isFetching: false, - })); - - const { getByText } = render(); - - // This is testing the date format specifically. - // if we update the date format, this test needs updating as well. - const selectorElement = getByText(formatDateDDMMMYYYY(defaultSnapshotItem.created_at, true)); - expect(selectorElement).toBeInTheDocument(); +describe('SnapshotSelector', () => { + beforeEach(() => { + mockNavigate.mockClear(); + mockSearchParams.delete('tab'); + }); + + it('renders the selected snapshot label', () => { + (useGetSnapshotList as jest.Mock).mockImplementation(() => ({ + data: { + meta: defaultMetaItem, + data: [defaultSnapshotItem], + }, + isLoading: false, + isFetching: false, + })); + + render(); + + // This is testing the date format specifically. + // if we update the date format, this test needs updating as well. + const selectorElement = screen.getByText( + formatDateDDMMMYYYY(defaultSnapshotItem.created_at, true), + ); + expect(selectorElement).toBeInTheDocument(); + }); + + it('preserves the active tab when selecting a different snapshot', async () => { + const user = userEvent.setup(); + const alternateSnapshot = { + ...defaultSnapshotItem, + uuid: '11111111-1111-4111-8111-111111111111', + created_at: '2024-02-08T20:23:32.711372-06:00', + }; + + mockSearchParams.set('tab', SnapshotDetailTab.CHANGES); + + (useGetSnapshotList as jest.Mock).mockImplementation(() => ({ + data: { + meta: { ...defaultMetaItem, count: 2 }, + data: [defaultSnapshotItem, alternateSnapshot], + }, + isLoading: false, + isFetching: false, + })); + + render(); + + await user.click(screen.getByRole('button', { name: 'snapshot selector' })); + await user.click( + screen.getByRole('menuitem', { + name: formatDateDDMMMYYYY(alternateSnapshot.created_at, true), + }), + ); + + expect(mockNavigate).toHaveBeenCalledWith( + `someUrl/repositories/repo-uuid/snapshots/${alternateSnapshot.uuid}?tab=${SnapshotDetailTab.CHANGES}`, + ); + }); }); diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotSelector.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotSelector.tsx index 8e6c62f63..f5d0658b5 100644 --- a/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotSelector.tsx +++ b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/SnapshotSelector.tsx @@ -7,7 +7,7 @@ import { MenuToggle, } from '@patternfly/react-core'; import { createUseStyles } from 'react-jss'; -import { useNavigate, useParams } from 'react-router-dom'; +import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { useGetSnapshotList } from 'services/Content/ContentQueries'; import { useMemo, useState } from 'react'; import useRootPath from 'Hooks/useRootPath'; @@ -27,6 +27,7 @@ export function SnapshotSelector() { const classes = useStyles(); const rootPath = useRootPath(); const navigate = useNavigate(); + const [urlSearchParams] = useSearchParams(); const [selectorOpen, setSelectorOpen] = useState(false); const { repoUUID: uuid = '', snapshotUUID = '' } = useParams(); @@ -48,7 +49,12 @@ export function SnapshotSelector() { }, [data?.data]); const setSelected = (selectedDate: string) => { - navigate(`${rootPath}/${REPOSITORIES_ROUTE}/${uuid}/snapshots/${dateMapper[selectedDate]}`); + const activeTab = urlSearchParams.get('tab'); + navigate( + `${rootPath}/${REPOSITORIES_ROUTE}/${uuid}/snapshots/${dateMapper[selectedDate]}${ + activeTab ? `?tab=${activeTab}` : '' + }`, + ); }; return ( diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/Tabs/SnapshotChangesTab.test.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/Tabs/SnapshotChangesTab.test.tsx new file mode 100644 index 000000000..2f343ea6f --- /dev/null +++ b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/Tabs/SnapshotChangesTab.test.tsx @@ -0,0 +1,132 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { SnapshotChangesTab } from './SnapshotChangesTab'; +import { useGetSnapshotDetailQuery } from 'services/Content/ContentQueries'; +import { + defaultPackageItem, + defaultRemovedPackageItem, + defaultSnapshotDetailItem, +} from 'testingHelpers'; + +const mockOnClose = jest.fn(); + +jest.mock('services/Content/ContentQueries', () => ({ + useGetSnapshotDetailQuery: jest.fn(), +})); + +jest.mock('Hooks/navigation/useNavigateTo', () => ({ + useNavigateTo: () => mockOnClose, +})); + +jest.mock('react-router-dom', () => ({ + useParams: () => ({ + repoUUID: '11111111-1111-4111-8111-111111111111', + snapshotUUID: '22222222-2222-4222-8222-222222222222', + }), +})); + +describe('SnapshotChangesTab', () => { + beforeEach(() => { + mockOnClose.mockClear(); + localStorage.clear(); + }); + + const mockSnapshotDetail = (overrides = {}) => { + (useGetSnapshotDetailQuery as jest.Mock).mockImplementation(() => ({ + isLoading: false, + isFetching: false, + isError: false, + data: { + ...defaultSnapshotDetailItem, + ...overrides, + }, + })); + }; + + it('renders add, remove, and update icons in the name column', () => { + mockSnapshotDetail({ + added_packages: [ + { ...defaultPackageItem, name: 'bash', version: '5.2.0', release: '1.el9' }, + { ...defaultPackageItem, name: 'dnf', version: '4.18.0', release: '3.el9' }, + ], + removed_packages: [ + { ...defaultRemovedPackageItem, name: 'dnf', version: '4.14.0', release: '1.el9' }, + { ...defaultRemovedPackageItem, name: 'vim', version: '9.0.0', release: '2.el9' }, + ], + }); + + render(); + + const grid = screen.getByRole('grid', { name: 'Snapshot changes table' }); + + expect(within(grid).getAllByRole('row')).toHaveLength(4); + expect(screen.getByText('bash')).toBeInTheDocument(); + expect(screen.getAllByText('dnf')).toHaveLength(2); + expect(screen.getByText('vim')).toBeInTheDocument(); + expect(screen.getByText('4.18.0')).toBeInTheDocument(); + expect(screen.getByText('4.14.0')).toBeInTheDocument(); + expect(screen.getByText('replacing')).toBeInTheDocument(); + expect(screen.getByLabelText('Added package')).toBeInTheDocument(); + expect(screen.getByLabelText('Removed package')).toBeInTheDocument(); + expect(screen.getByLabelText('Updated package')).toBeInTheDocument(); + }); + + it('filters rows by package name', async () => { + const user = userEvent.setup(); + + mockSnapshotDetail({ + added_packages: [ + { ...defaultPackageItem, name: 'bash' }, + { ...defaultPackageItem, name: 'dnf' }, + ], + removed_packages: [{ ...defaultRemovedPackageItem, name: 'vim' }], + }); + + render(); + + const grid = screen.getByRole('grid', { name: 'Snapshot changes table' }); + + await user.type(screen.getByPlaceholderText('Filter by name'), 'vim'); + + expect(within(grid).queryByText('bash')).not.toBeInTheDocument(); + expect(within(grid).queryByText('dnf')).not.toBeInTheDocument(); + expect(within(grid).getAllByText('vim').length).toBeGreaterThan(0); + }); + + it('pages through merged rows locally', async () => { + const user = userEvent.setup(); + localStorage.setItem('snapshotChangesPerPage', '1'); + + mockSnapshotDetail({ + added_packages: [ + { ...defaultPackageItem, name: 'bash' }, + { ...defaultPackageItem, name: 'dnf' }, + ], + removed_packages: [{ ...defaultRemovedPackageItem, name: 'vim' }], + }); + + render(); + + const grid = screen.getByRole('grid', { name: 'Snapshot changes table' }); + + expect(within(grid).getByText('bash')).toBeInTheDocument(); + expect(within(grid).queryByText('dnf')).not.toBeInTheDocument(); + + await user.click(screen.getAllByRole('button', { name: /Go to next page/i })[0]); + + expect(within(grid).getByText('dnf')).toBeInTheDocument(); + }); + + it('shows an empty state when there are no package changes', () => { + mockSnapshotDetail({ + added_packages: [], + removed_packages: [], + }); + + render(); + + expect(screen.getByText('No package changes')).toBeInTheDocument(); + expect(screen.getByText('This snapshot has no package changes.')).toBeInTheDocument(); + }); +}); diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/Tabs/SnapshotChangesTab.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/Tabs/SnapshotChangesTab.tsx new file mode 100644 index 000000000..9ad8fc5bb --- /dev/null +++ b/src/Pages/Repositories/ContentListTable/components/SnapshotDetailsModal/Tabs/SnapshotChangesTab.tsx @@ -0,0 +1,364 @@ +import { Pagination } from '@patternfly/react-core'; +import { DataView, DataViewState } from '@patternfly/react-data-view/dist/dynamic/DataView'; +import { DataViewFilters } from '@patternfly/react-data-view/dist/dynamic/DataViewFilters'; +import { DataViewTextFilter } from '@patternfly/react-data-view/dist/dynamic/DataViewTextFilter'; +import { DataViewToolbar } from '@patternfly/react-data-view/dist/dynamic/DataViewToolbar'; +import { + DataViewTable, + DataViewTh, + DataViewTrObject, +} from '@patternfly/react-data-view/dist/dynamic/DataViewTable'; +import { SkeletonTableBody } from '@patternfly/react-component-groups'; +import { LongArrowAltDownIcon, LongArrowAltUpIcon } from '@patternfly/react-icons'; +import spacing from '@patternfly/react-styles/css/utilities/Spacing/spacing'; +import { + t_global_color_status_danger_default, + t_global_color_status_success_default, + t_global_text_color_subtle, +} from '@patternfly/react-tokens'; +import { useEffect, useMemo } from 'react'; +import { createUseStyles } from 'react-jss'; + +import useSafeUUIDParam from 'Hooks/useSafeUUIDParam'; +import { useNavigateTo } from 'Hooks/navigation/useNavigateTo'; +import EmptyTableDataView from 'components/EmptyTableDataView/EmptyTableDataView'; +import { usePackageTableFilters } from 'components/Tables/Packages/hooks/usePackageTableFilters'; +import { useTablePaginationLocalStorage } from 'components/Tables/Generic/hooks/useTablePaginationLocalStorage'; +import { type Package } from 'services/Content/ContentApi'; +import { useGetSnapshotDetailQuery } from 'services/Content/ContentQueries'; + +const perPageKey = 'snapshotChangesPerPage'; + +const useStyles = createUseStyles({ + cellLines: { + display: 'flex', + flexDirection: 'column', + gap: '4px', + }, + cellLine: { + display: 'inline-flex', + alignItems: 'baseline', + gap: '8px', + }, + nameWithStatus: { + display: 'inline-flex', + alignItems: 'baseline', + gap: '8px', + }, + subduedLine: { + color: t_global_text_color_subtle.var, + }, + replacingText: { + fontWeight: 400, + }, + addedIcon: { + color: t_global_color_status_success_default.var, + }, + removedIcon: { + color: t_global_color_status_danger_default.var, + }, + updatedIcon: { + color: t_global_color_status_success_default.var, + }, +}); + +type SnapshotChangeRow = { + id: string; + name: string; + arch: string; + added?: Package; + removed?: Package; +}; + +type PackageField = 'name' | 'version' | 'release' | 'arch'; +type PackageStatus = 'default' | 'added' | 'removed' | 'updated'; + +const packageKey = ({ name, arch }: Package) => `${name}::${arch}`; + +export const buildSnapshotChangeRows = ( + addedPackages: Package[] = [], + removedPackages: Package[] = [], +): SnapshotChangeRow[] => { + const groupedPackages = new Map< + string, + { name: string; arch: string; added: Package[]; removed: Package[] } + >(); + const orderedKeys: string[] = []; + + const addToGroup = (packageItem: Package, changeType: 'added' | 'removed') => { + const key = packageKey(packageItem); + if (!groupedPackages.has(key)) { + orderedKeys.push(key); + groupedPackages.set(key, { + name: packageItem.name, + arch: packageItem.arch, + added: [], + removed: [], + }); + } + + groupedPackages.get(key)?.[changeType].push(packageItem); + }; + + addedPackages.forEach((packageItem) => addToGroup(packageItem, 'added')); + removedPackages.forEach((packageItem) => addToGroup(packageItem, 'removed')); + + return orderedKeys.flatMap((key) => { + const groupedPackage = groupedPackages.get(key); + if (!groupedPackage) { + return []; + } + + const rowCount = Math.max(groupedPackage.added.length, groupedPackage.removed.length); + + return Array.from({ length: rowCount }, (_, index) => ({ + id: `${key}-${index}`, + name: groupedPackage.name, + arch: groupedPackage.arch, + added: groupedPackage.added[index], + removed: groupedPackage.removed[index], + })); + }); +}; + +export function SnapshotChangesTab() { + const classes = useStyles(); + const repoUUID = useSafeUUIDParam('repoUUID'); + const snapshotUUID = useSafeUUIDParam('snapshotUUID'); + const onClose = useNavigateTo('root'); + const paginationData = useTablePaginationLocalStorage({ key: perPageKey }); + const filterData = usePackageTableFilters(); + const { page, perPage, setPage } = paginationData; + const { filters, onSetFilters, clearAllFilters } = filterData; + + const { isLoading, isFetching, isError, data } = useGetSnapshotDetailQuery( + repoUUID, + snapshotUUID, + ); + + useEffect(() => { + if (isError) { + onClose(); + } + }, [isError, onClose]); + + const allRows = useMemo( + () => buildSnapshotChangeRows(data?.added_packages ?? [], data?.removed_packages ?? []), + [data?.added_packages, data?.removed_packages], + ); + + const filteredRows = useMemo(() => { + if (!filters.search) { + return allRows; + } + + const normalizedSearch = filters.search.trim().toLowerCase(); + return allRows.filter(({ name }) => name.toLowerCase().includes(normalizedSearch)); + }, [allRows, filters.search]); + + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(filteredRows.length / perPage)); + if (page > maxPage) { + setPage(maxPage); + } + }, [filteredRows.length, page, perPage, setPage]); + + const visibleRows = useMemo(() => { + const startIndex = (page - 1) * perPage; + return filteredRows.slice(startIndex, startIndex + perPage); + }, [filteredRows, page, perPage]); + + const isFetchingOrLoading = isFetching || isLoading; + const activeState = useMemo(() => { + if (isFetchingOrLoading) { + return DataViewState.loading; + } + + if (!filteredRows.length) { + return DataViewState.empty; + } + + return undefined; + }, [filteredRows.length, isFetchingOrLoading]); + + const paginationProps = { + itemCount: filteredRows.length, + page, + perPage, + onSetPage: paginationData.onSetPage, + onPerPageSelect: paginationData.onPerPageSelect, + }; + + const clearAllFiltersAndResetPage = () => { + clearAllFilters(); + setPage(1); + }; + + const handleFilterChange = (_key, newValues) => { + onSetFilters(newValues); + setPage(1); + }; + + const renderCellLine = ( + packageItem: Package | undefined, + field: PackageField, + { + status = 'default', + subdued = false, + cueText, + }: { status?: PackageStatus; subdued?: boolean; cueText?: string } = {}, + ) => { + if (!packageItem) { + return null; + } + + const lineClasses = [classes.cellLine]; + + if (subdued) { + lineClasses.push(classes.subduedLine); + } + + const renderStatusIcon = () => { + switch (status) { + case 'added': + return ( + + + ); + case 'removed': + return ( + + + ); + case 'updated': + return ( + + + ); + default: + return null; + } + }; + + return ( + + {field === 'name' ? ( + + {status !== 'default' ? renderStatusIcon() : null} + {cueText ? {cueText} : null} + {packageItem[field]} + + ) : ( + {packageItem[field]} + )} + + ); + }; + + const renderChangeCell = (row: SnapshotChangeRow, field: PackageField) => { + const hasAddedPackage = Boolean(row.added); + const hasRemovedPackage = Boolean(row.removed); + const addedOnly = hasAddedPackage && !hasRemovedPackage; + const removedOnly = !hasAddedPackage && hasRemovedPackage; + const replacing = hasAddedPackage && hasRemovedPackage; + + return ( +
+ {renderCellLine(hasAddedPackage ? row.added : row.removed, field, { + status: addedOnly ? 'added' : removedOnly ? 'removed' : replacing ? 'updated' : 'default', + })} + {replacing + ? renderCellLine(row.removed, field, { + subdued: true, + cueText: field === 'name' ? 'replacing' : undefined, + }) + : null} +
+ ); + }; + + const columns = [ + { name: 'Name' }, + { name: 'Version' }, + { name: 'Release' }, + { name: 'Architecture' }, + ]; + + const dataViewColumns: DataViewTh[] = columns.map(({ name }) => ({ cell: name })); + const dataViewRows: DataViewTrObject[] = visibleRows.map((row) => ({ + id: row.id, + row: [ + { cell: renderChangeCell(row, 'name') }, + { cell: renderChangeCell(row, 'version') }, + { cell: renderChangeCell(row, 'release') }, + { cell: renderChangeCell(row, 'arch') }, + ], + })); + + const topPagination = ( + + ); + + const bottomPagination = ( + + ); + + const searchFilter = ( + + + + ); + + const emptyStateTable = ( + + ); + + const loadingStateTable = ; + + return ( + + + + + + ); +} diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.test.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.test.tsx index a17e8317a..710f1e2c7 100644 --- a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.test.tsx +++ b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.test.tsx @@ -1,4 +1,5 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import SnapshotListModal from './SnapshotListModal'; import { ReactQueryTestWrapper, @@ -8,6 +9,9 @@ import { } from 'testingHelpers'; import { useFetchContent, useGetSnapshotList } from 'services/Content/ContentQueries'; import { ContentOrigin } from 'services/Content/ContentApi'; +import { SnapshotDetailTab } from '../SnapshotDetailsModal/SnapshotDetailsModal'; + +const mockNavigate = jest.fn(); jest.mock('Hooks/useRootPath', () => () => 'someUrl'); @@ -19,7 +23,7 @@ jest.mock('services/Content/ContentQueries', () => ({ })); jest.mock('react-router-dom', () => ({ - useNavigate: jest.fn(), + useNavigate: () => mockNavigate, Outlet: () => <>, useParams: () => ({ repoUUID: 'some-uuid', @@ -34,6 +38,7 @@ jest.mock('middleware/AppContext', () => ({ })); it('Render 1 item', () => { + mockNavigate.mockClear(); (useFetchContent as jest.Mock).mockImplementation(() => ({ data: defaultContentItemWithSnapshot, })); @@ -59,6 +64,7 @@ it('Render 1 item', () => { }); it('Render 20 items', () => { + mockNavigate.mockClear(); (useFetchContent as jest.Mock).mockImplementation(() => ({ data: defaultContentItemWithSnapshot, })); @@ -93,3 +99,60 @@ it('Render 20 items', () => { getByText((defaultSnapshotItem.content_counts['rpm.package'] as number)?.toString()), ).toBeInTheDocument(); }); + +it('navigates to the changes tab from the change column', async () => { + const user = userEvent.setup(); + mockNavigate.mockClear(); + (useFetchContent as jest.Mock).mockImplementation(() => ({ + data: defaultContentItemWithSnapshot, + })); + (useGetSnapshotList as jest.Mock).mockImplementation(() => ({ + data: { + meta: defaultMetaItem, + data: [defaultSnapshotItem], + }, + isLoading: false, + isFetching: false, + })); + + render( + + + , + ); + + await user.click(screen.getByRole('button', { name: 'View snapshot changes' })); + + expect(mockNavigate).toHaveBeenCalledWith( + `someUrl/repositories/some-uuid/snapshots/${defaultSnapshotItem.uuid}?tab=${SnapshotDetailTab.CHANGES}`, + ); +}); + +it('disables the changes link when a snapshot has no package changes', () => { + mockNavigate.mockClear(); + (useFetchContent as jest.Mock).mockImplementation(() => ({ + data: defaultContentItemWithSnapshot, + })); + (useGetSnapshotList as jest.Mock).mockImplementation(() => ({ + data: { + meta: defaultMetaItem, + data: [ + { + ...defaultSnapshotItem, + added_counts: { ...defaultSnapshotItem.added_counts, 'rpm.package': 0 }, + removed_counts: { ...defaultSnapshotItem.removed_counts, 'rpm.package': 0 }, + }, + ], + }, + isLoading: false, + isFetching: false, + })); + + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'View snapshot changes' })).toBeDisabled(); +}); diff --git a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.tsx b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.tsx index faf97ef3a..0202e8d6a 100644 --- a/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.tsx +++ b/src/Pages/Repositories/ContentListTable/components/SnapshotListModal/SnapshotListModal.tsx @@ -337,83 +337,104 @@ const SnapshotListModal = () => { removed_counts, }: SnapshotItem, index: number, - ) => ( - - - - onSelectSnapshot(snap_uuid, isSelecting), - isSelected: checkedSnapshots.has(snap_uuid), - }} - /> - - {formatDateDDMMMYYYY(created_at, true)} - - - - - - - - - - - - - - - + onSelectSnapshot(snap_uuid, isSelecting), + isSelected: checkedSnapshots.has(snap_uuid), + }} + /> + + {formatDateDDMMMYYYY(created_at, true)} + + + + + + + + + + + - - - ), + + + + + + + + + ); + }, )} diff --git a/src/services/Content/ContentApi.ts b/src/services/Content/ContentApi.ts index 97258c1a8..ba7b8b5cf 100644 --- a/src/services/Content/ContentApi.ts +++ b/src/services/Content/ContentApi.ts @@ -240,10 +240,9 @@ export type ContentCounts = { 'rpm.repo_metadata_file'?: number; }; -export interface SnapshotItem { +interface SnapshotBase { uuid: string; created_at: string; - distribution_path: string; content_counts: ContentCounts; added_counts: ContentCounts; removed_counts: ContentCounts; @@ -251,6 +250,19 @@ export interface SnapshotItem { repository_uuid: string; } +export interface SnapshotItem extends SnapshotBase { + distribution_path: string; +} + +export interface SnapshotDetail extends SnapshotBase { + repository_path?: string; + distribution_path?: string; + url?: string; + detected_os_version?: string; + added_packages?: Package[]; + removed_packages?: Package[]; +} + export type SnapshotByDateResponse = { data: SnapshotForDate[]; }; @@ -514,6 +526,16 @@ export const getSnapshotList: ( return data; }; +export const getSnapshotDetail: ( + repo_uuid: string, + snapshot_uuid: string, +) => Promise = async (repo_uuid: string, snapshot_uuid: string) => { + const { data } = await axios.get( + `/api/content-sources/v1.0/repositories/${repo_uuid}/snapshots/${snapshot_uuid}`, + ); + return data; +}; + export const introspectRepository: ( request: IntrospectRepositoryRequestItem, ) => Promise = async (request) => { diff --git a/src/services/Content/ContentQueries.ts b/src/services/Content/ContentQueries.ts index ee2e4e1f4..2c3a80cce 100644 --- a/src/services/Content/ContentQueries.ts +++ b/src/services/Content/ContentQueries.ts @@ -37,6 +37,7 @@ import { ContentOrigin, getRepoConfigFile, triggerSnapshot, + getSnapshotDetail, getSnapshotsByDate, getSnapshotPackages, getSnapshotErrata, @@ -67,6 +68,7 @@ export const CREATE_PARAMS_KEY = 'CREATE_PARAMS_KEY'; export const PACKAGES_KEY = 'PACKAGES_KEY'; export const SNAPSHOT_PACKAGES_KEY = 'SNAPSHOT_PACKAGES_KEY'; export const SNAPSHOT_ERRATA_KEY = 'SNAPSHOT_ERRATA_KEY'; +export const SNAPSHOT_DETAIL_KEY = 'SNAPSHOT_DETAIL_KEY'; export const LIST_SNAPSHOTS_KEY = 'LIST_SNAPSHOTS_KEY'; export const CONTENT_ITEM_KEY = 'CONTENT_ITEM_KEY'; export const REPO_CONFIG_FILE_KEY = 'REPO_CONFIG_FILE_KEY'; @@ -646,6 +648,18 @@ export const useGetSnapshotList = (uuid: string, page: number, limit: number, so }, }); +export const useGetSnapshotDetailQuery = (repo_uuid: string, snapshot_uuid: string) => + useQuery({ + queryKey: [SNAPSHOT_DETAIL_KEY, repo_uuid, snapshot_uuid], + queryFn: () => getSnapshotDetail(repo_uuid, snapshot_uuid), + placeholderData: keepPreviousData, + staleTime: 60000, + meta: { + title: 'Unable to find snapshot details with the given UUID.', + id: 'snapshot-detail-error', + }, + }); + export const useGetPackagesQuery = ( uuid: string, page: number, @@ -893,6 +907,7 @@ export const useBulkDeleteSnapshotsMutate = ( queryClient.invalidateQueries({ queryKey: [LIST_SNAPSHOTS_KEY] }); queryClient.invalidateQueries({ queryKey: [SNAPSHOT_ERRATA_KEY] }); queryClient.invalidateQueries({ queryKey: [SNAPSHOT_PACKAGES_KEY] }); + queryClient.invalidateQueries({ queryKey: [SNAPSHOT_DETAIL_KEY] }); queryClient.invalidateQueries({ queryKey: [REPO_CONFIG_FILE_KEY] }); queryClient.invalidateQueries({ queryKey: [LATEST_REPO_CONFIG_FILE_KEY] }); }, diff --git a/src/testingHelpers.tsx b/src/testingHelpers.tsx index 40edbc16e..997d2d4e3 100644 --- a/src/testingHelpers.tsx +++ b/src/testingHelpers.tsx @@ -6,6 +6,7 @@ import { PopularRepository, RepositoryParamsResponse, SnapshotByDateResponse, + SnapshotDetail, SnapshotForDate, SnapshotItem, ValidationResponse, @@ -587,6 +588,21 @@ export const defaultPackageItem: Package = { summary: 'Core libraries for 389 Banana Server', }; +export const defaultRemovedPackageItem: Package = { + ...defaultPackageItem, + version: '1.2.2', + release: '5.el9_fruit', +}; + +export const defaultSnapshotDetailItem: SnapshotDetail = { + ...defaultSnapshotItem, + repository_path: defaultSnapshotItem.distribution_path, + added_packages: [defaultPackageItem], + removed_packages: [defaultRemovedPackageItem], + url: '', + detected_os_version: '9', +}; + export const defaultErrataItem: ErrataItem = { id: '0190a44a-fcd3-7aa1-a280-033399516ead', errata_id: 'RHSA-2024:4502',