diff --git a/src/gitops/components/application/ApplicationResourcesView.tsx b/src/gitops/components/application/ApplicationResourcesView.tsx index bfb027c26..7e8f583bb 100644 --- a/src/gitops/components/application/ApplicationResourcesView.tsx +++ b/src/gitops/components/application/ApplicationResourcesView.tsx @@ -27,7 +27,11 @@ import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/Dat import { CubesIcon } from '@patternfly/react-icons'; import { Tbody, Td, Tr } from '@patternfly/react-table'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../shared/DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from '../shared/DataView'; import ResourceActionsCell from '../shared/ResourceActionsCell/ResourceActionsCell'; import { ApplicationGraphView } from './graph/ApplicationGraphView'; @@ -59,7 +63,8 @@ const ApplicationResourcesView: React.FC = ({ [], ); - const { sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); + const { searchParams, sortBy, direction, getSortParams } = + useGitOpsDataViewSort(columnSortConfig); const columnsDV = useResourceColumnsDV(getSortParams); const sortedResources = React.useMemo( () => sortData(resources, sortBy, direction), @@ -81,8 +86,18 @@ const ApplicationResourcesView: React.FC = ({ resourceFilters, ); + const { + pagination, + pagedItems: pagedResources, + itemCount, + } = useGitOpsListPagePagination({ + items: filteredResources, + namespace: application?.metadata?.namespace, + searchParams, + }); + const isEmptyResources = filteredResources.length === 0; - const rows = useResourceRowsDV(filteredResources, application, argoBaseURL); + const rows = useResourceRowsDV(pagedResources, application, argoBaseURL); const isListView = viewType === ApplicationResourcesViewType.list; const empty = ( @@ -148,6 +163,8 @@ const ApplicationResourcesView: React.FC = ({ emptyState={empty} isEmpty={isEmptyResources} activeState={isEmptyResources ? DataViewState.empty : null} + itemCount={itemCount} + pagination={pagination} /> ) : (
diff --git a/src/gitops/components/application/ApplicationSourcesTab.tsx b/src/gitops/components/application/ApplicationSourcesTab.tsx index 07268e9fa..577e3b83d 100644 --- a/src/gitops/components/application/ApplicationSourcesTab.tsx +++ b/src/gitops/components/application/ApplicationSourcesTab.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import { RouteComponentProps } from 'react-router'; +import { useSearchParams } from 'react-router-dom-v5-compat'; import { useArgoServer } from '@gitops/hooks/useArgoServer'; import { ApplicationKind, ApplicationSource } from '@gitops/models/ApplicationModel'; @@ -25,7 +26,7 @@ import { CubesIcon, GithubIcon } from '@patternfly/react-icons'; import { Tbody, Td, Tr } from '@patternfly/react-table'; import ArgoCDLink from '../shared/ArgoCDLink/ArgoCDLink'; -import { GitOpsDataViewTable } from '../shared/DataView'; +import { GitOpsDataViewTable, useGitOpsListPagePagination } from '../shared/DataView'; type ApplicationDetailsTabProps = RouteComponentProps<{ ns: string; @@ -183,7 +184,17 @@ export const useRowsDV = (sources: ApplicationSource[]): DataViewTr[] => { export const SourceList: React.FC = ({ sources, obj, argoServer }) => { const columns = useColumnsDV(); - const rows = useRowsDV(sources); + const [searchParams] = useSearchParams(); + const { + pagination, + pagedItems: pagedSources, + itemCount, + } = useGitOpsListPagePagination({ + items: sources, + namespace: obj?.metadata?.namespace, + searchParams, + }); + const rows = useRowsDV(pagedSources); const argoUrl = getApplicationArgoUrl(argoServer, obj); const empty = ( @@ -222,7 +233,9 @@ export const SourceList: React.FC = ({ sources, obj, argoServer rows={rows} columns={columns} emptyState={empty} - isEmpty={rows.length === 0} + isEmpty={sources.length === 0} + itemCount={itemCount} + pagination={pagination} /> ); diff --git a/src/gitops/components/application/ApplicationSyncStatusTab.tsx b/src/gitops/components/application/ApplicationSyncStatusTab.tsx index 3633448ea..cf2373bf9 100644 --- a/src/gitops/components/application/ApplicationSyncStatusTab.tsx +++ b/src/gitops/components/application/ApplicationSyncStatusTab.tsx @@ -36,7 +36,11 @@ import { CubesIcon } from '@patternfly/react-icons'; import { Tbody, Td, ThProps, Tr } from '@patternfly/react-table'; import { DetailsDescriptionGroup } from '../shared/BaseDetailsSummary/BaseDetailsSummary'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../shared/DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from '../shared/DataView'; import ResourceActionsCell from '../shared/ResourceActionsCell/ResourceActionsCell'; import { ConditionsPopover } from './Conditions/ConditionsPopover'; @@ -64,13 +68,24 @@ const ApplicationSyncStatusTab: React.FC = ({ obj [], ); - const { sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); + const { searchParams, sortBy, direction, getSortParams } = + useGitOpsDataViewSort(columnSortConfig); const columnsDV = useResourceColumnsDV(getSortParams); const sortedResources = React.useMemo(() => { return sortData(resources, sortBy, direction); }, [resources, sortBy, direction]); - const rows = useResourceRowsDV(sortedResources, obj, argoUrl); + const { + pagination, + pagedItems: pagedResources, + itemCount, + } = useGitOpsListPagePagination({ + items: sortedResources, + namespace: obj?.metadata?.namespace, + searchParams, + }); + + const rows = useResourceRowsDV(pagedResources, obj, argoUrl); const empty = ( @@ -252,6 +267,8 @@ const ApplicationSyncStatusTab: React.FC = ({ obj emptyState={empty} isEmpty={sortedResources.length === 0} activeState={resources.length === 0 ? DataViewState.empty : null} + itemCount={itemCount} + pagination={pagination} />
diff --git a/src/gitops/components/application/History/History.tsx b/src/gitops/components/application/History/History.tsx index d52a32fa5..d6d81a578 100644 --- a/src/gitops/components/application/History/History.tsx +++ b/src/gitops/components/application/History/History.tsx @@ -19,7 +19,11 @@ import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/Dat import { CubesIcon } from '@patternfly/react-icons'; import { Tbody, Td, ThProps, Tr } from '@patternfly/react-table'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../../shared/DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from '../../shared/DataView'; import './History.scss'; @@ -35,14 +39,26 @@ const HistoryList: React.FC = ({ history, obj }) => { [], ); - const { sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); + const { searchParams, sortBy, direction, getSortParams } = + useGitOpsDataViewSort(columnSortConfig); const columnsDV = useColumnsDV(getSortParams); - const sortedHistory = React.useMemo(() => { - return sortData(history, sortBy, direction); - }, [history, sortBy, direction]); + const displayHistory = React.useMemo( + () => sortData([...history].reverse(), sortBy, direction), + [history, sortBy, direction], + ); + + const { + pagination, + pagedItems: pagedHistory, + itemCount, + } = useGitOpsListPagePagination({ + items: displayHistory, + namespace: obj?.metadata?.namespace, + searchParams, + }); - const rows = useRowsDV(sortedHistory, obj); + const rows = useRowsDV(pagedHistory, obj); const argoServer = useArgoServer(obj); const argoUrl = getApplicationArgoUrl(argoServer, obj); @@ -85,7 +101,9 @@ const HistoryList: React.FC = ({ history, obj }) => { rows={rows} columns={columnsDV} emptyState={empty} - isEmpty={rows.length === 0} + isEmpty={displayHistory.length === 0} + itemCount={itemCount} + pagination={pagination} /> ); @@ -187,7 +205,7 @@ const useRowsDV = (history: ApplicationHistory[], app: ApplicationKind): DataVie }, ]); }); - return rows.reverse(); + return rows; }; const useColumnsDV = (getSortParams: (columnIndex: number) => ThProps['sort']) => { diff --git a/src/gitops/components/imageupdater/ImageUpdaterRecentUpdatesTab.tsx b/src/gitops/components/imageupdater/ImageUpdaterRecentUpdatesTab.tsx index 6bd22c925..6e2e8fdf3 100644 --- a/src/gitops/components/imageupdater/ImageUpdaterRecentUpdatesTab.tsx +++ b/src/gitops/components/imageupdater/ImageUpdaterRecentUpdatesTab.tsx @@ -17,7 +17,11 @@ import { Tbody, Td, ThProps, Tr } from '@patternfly/react-table'; import { ImageUpdaterKind, ImageUpdaterRecentUpdate } from '../../models/ImageUpdaterModel'; import { useGitOpsTranslation } from '../../utils/hooks/useGitOpsTranslation'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../shared/DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from '../shared/DataView'; type ImageUpdaterRecentUpdatesTabProps = RouteComponentProps<{ ns: string; name: string }> & { obj?: ImageUpdaterKind; @@ -34,7 +38,8 @@ const ImageUpdaterRecentUpdatesTab: React.FC [], ); - const { sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); + const { searchParams, sortBy, direction, getSortParams } = + useGitOpsDataViewSort(columnSortConfig); const columnsDV = useColumnsDV(getSortParams, t); @@ -43,7 +48,17 @@ const ImageUpdaterRecentUpdatesTab: React.FC return sortData(updates, sortBy, direction); }, [obj, sortBy, direction]); - const rows = useRowsDV(sortedUpdates); + const { + pagination, + pagedItems: pagedUpdates, + itemCount, + } = useGitOpsListPagePagination({ + items: sortedUpdates, + namespace: obj?.metadata?.namespace, + searchParams, + }); + + const rows = useRowsDV(pagedUpdates); if (!obj) { return null; @@ -81,7 +96,9 @@ const ImageUpdaterRecentUpdatesTab: React.FC rows={rows} columns={columnsDV} emptyState={empty} - isEmpty={rows.length === 0} + isEmpty={sortedUpdates.length === 0} + itemCount={itemCount} + pagination={pagination} /> diff --git a/src/gitops/components/project/ProjectRolesTab.tsx b/src/gitops/components/project/ProjectRolesTab.tsx index 00b6aafe9..6dcc3d1ed 100644 --- a/src/gitops/components/project/ProjectRolesTab.tsx +++ b/src/gitops/components/project/ProjectRolesTab.tsx @@ -21,7 +21,11 @@ import { AppProjectKind, Role } from '../../models/AppProjectModel'; import { ArgoServer, getArgoServerForProject } from '../../utils/gitops'; import { useGitOpsTranslation } from '../../utils/hooks/useGitOpsTranslation'; import { ArgoCDLink } from '../shared/ArgoCDLink/ArgoCDLink'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../shared/DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from '../shared/DataView'; /** * Parses an Argo CD policy string and returns a formatted React element for tooltip @@ -104,13 +108,24 @@ const ProjectRolesTab: React.FC = ({ obj }) => { [], ); - const { sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); + const { searchParams, sortBy, direction, getSortParams } = + useGitOpsDataViewSort(columnSortConfig); const columnsDV = useRolesColumnsDV(getSortParams, t); const sortedRoles = React.useMemo(() => { return sortRolesData(roles, sortBy, direction); }, [roles, sortBy, direction]); - const rows = useRolesRowsDV(sortedRoles, t); + const { + pagination, + pagedItems: pagedRoles, + itemCount, + } = useGitOpsListPagePagination({ + items: sortedRoles, + namespace: obj?.metadata?.namespace, + searchParams, + }); + + const rows = useRolesRowsDV(pagedRoles, t); if (!obj) return null; @@ -149,6 +164,8 @@ const ProjectRolesTab: React.FC = ({ obj }) => { rows={rows} isEmpty={roles.length === 0} emptyState={empty} + itemCount={itemCount} + pagination={pagination} /> ); diff --git a/src/gitops/components/project/ProjectSyncWindowsTab.tsx b/src/gitops/components/project/ProjectSyncWindowsTab.tsx index c029bcb0d..18d57d0a2 100644 --- a/src/gitops/components/project/ProjectSyncWindowsTab.tsx +++ b/src/gitops/components/project/ProjectSyncWindowsTab.tsx @@ -20,7 +20,11 @@ import { AppProjectKind, SyncWindow } from '../../models/AppProjectModel'; import { ArgoServer, getArgoServerForProject } from '../../utils/gitops'; import { useGitOpsTranslation } from '../../utils/hooks/useGitOpsTranslation'; import { ArgoCDLink } from '../shared/ArgoCDLink/ArgoCDLink'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '../shared/DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from '../shared/DataView'; type ProjectSyncWindowsTabProps = RouteComponentProps<{ ns: string; name: string }> & { obj?: AppProjectKind; @@ -60,13 +64,24 @@ const ProjectSyncWindowsTab: React.FC = ({ obj }) => [], ); - const { sortBy, direction, getSortParams } = useGitOpsDataViewSort(columnSortConfig); + const { searchParams, sortBy, direction, getSortParams } = + useGitOpsDataViewSort(columnSortConfig); const columnsDV = useSyncWindowsColumnsDV(getSortParams, t); const sortedSyncWindows = React.useMemo(() => { return sortSyncWindowsData(syncWindows, sortBy, direction); }, [syncWindows, sortBy, direction]); - const rows = useSyncWindowsRowsDV(sortedSyncWindows, t); + const { + pagination, + pagedItems: pagedSyncWindows, + itemCount, + } = useGitOpsListPagePagination({ + items: sortedSyncWindows, + namespace: obj?.metadata?.namespace, + searchParams, + }); + + const rows = useSyncWindowsRowsDV(pagedSyncWindows, t); if (!obj) return null; @@ -105,6 +120,8 @@ const ProjectSyncWindowsTab: React.FC = ({ obj }) => rows={rows} isEmpty={syncWindows.length === 0} emptyState={empty} + itemCount={itemCount} + pagination={pagination} /> ); diff --git a/src/gitops/components/rollout/components/PodList/PodList.tsx b/src/gitops/components/rollout/components/PodList/PodList.tsx index 309dece75..34404b37c 100644 --- a/src/gitops/components/rollout/components/PodList/PodList.tsx +++ b/src/gitops/components/rollout/components/PodList/PodList.tsx @@ -1,7 +1,11 @@ import * as React from 'react'; import { useTranslation } from 'react-i18next'; -import { GitOpsDataViewTable, useGitOpsDataViewSort } from '@gitops/components/shared/DataView'; +import { + GitOpsDataViewTable, + useGitOpsDataViewSort, + useGitOpsListPagePagination, +} from '@gitops/components/shared/DataView'; import { PodTraffic } from '@gitops/topology/console/pod-traffic'; import { podPhase } from '@gitops/topology/console/PodsOverview'; import { PodKind } from '@gitops/topology/console/types'; @@ -452,6 +456,16 @@ export const PodList: React.FC = ({ rollout, namespace, selector } }); }, [filteredData, searchQuery]); + const { + pagination, + pagedItems: pagedPods, + itemCount, + } = useGitOpsListPagePagination({ + items: filteredBySearch, + namespace, + searchParams, + }); + const empty = ( @@ -471,7 +485,7 @@ export const PodList: React.FC = ({ rollout, namespace, selector } const isEmptyState = !loadError && filteredBySearch.length === 0; - const rows = usePodRowsDV(filteredBySearch, memResults, cpuResults, namespace); + const rows = usePodRowsDV(pagedPods, memResults, cpuResults, namespace); const topologyUrl = rollout?.metadata?.namespace ? '/topology/ns/' + @@ -496,6 +510,8 @@ export const PodList: React.FC = ({ rollout, namespace, selector } isEmpty={isEmptyState} emptyState={empty} isError={!!loadError} + itemCount={itemCount} + pagination={pagination} /> diff --git a/src/gitops/components/shared/DataView/gitOpsDataViewPagination.test.ts b/src/gitops/components/shared/DataView/gitOpsDataViewPagination.test.ts index 5df364c09..2a60a3c5d 100644 --- a/src/gitops/components/shared/DataView/gitOpsDataViewPagination.test.ts +++ b/src/gitops/components/shared/DataView/gitOpsDataViewPagination.test.ts @@ -1,4 +1,9 @@ -import { getGitOpsPaginationResetKey, paginateItems } from './gitOpsDataViewPagination'; +import { + getGitOpsPaginationResetKey, + GITOPS_DEFAULT_PER_PAGE, + GITOPS_PER_PAGE_OPTIONS, + paginateItems, +} from './gitOpsDataViewPagination'; describe('paginateItems', () => { const items = ['a', 'b', 'c', 'd', 'e']; @@ -83,4 +88,94 @@ describe('getGitOpsPaginationResetKey', () => { getGitOpsPaginationResetKey('argocd', labeled), ); }); + + it('resets for Application Resources list filters but not for page changes', () => { + const filtered = new URLSearchParams( + 'page=2&rowFilter-resource-sync=Synced&rowFilter-resource-kind=Deployment', + ); + const nextPage = new URLSearchParams( + 'page=4&rowFilter-resource-sync=Synced&rowFilter-resource-kind=Deployment', + ); + const otherKind = new URLSearchParams( + 'page=2&rowFilter-resource-sync=Synced&rowFilter-resource-kind=Service', + ); + + expect(getGitOpsPaginationResetKey('openshift-gitops', filtered)).toBe( + getGitOpsPaginationResetKey('openshift-gitops', nextPage), + ); + expect(getGitOpsPaginationResetKey('openshift-gitops', filtered)).not.toBe( + getGitOpsPaginationResetKey('openshift-gitops', otherKind), + ); + }); +}); + +describe('GitOps detail-tab pagination contract', () => { + it('uses Console-compatible page size defaults', () => { + expect(GITOPS_DEFAULT_PER_PAGE).toBe(50); + expect(GITOPS_PER_PAGE_OPTIONS.map((option) => option.value)).toEqual([10, 20, 50, 100]); + }); + + it('paginates AppProject roles after sort', () => { + const roles = [ + { name: 'ci-role' }, + { name: 'read-only' }, + { name: 'admin' }, + { name: 'developer' }, + ].sort((a, b) => a.name.localeCompare(b.name)); + + expect(paginateItems(roles, 1, 2).map((role) => role.name)).toEqual(['admin', 'ci-role']); + expect(paginateItems(roles, 2, 2).map((role) => role.name)).toEqual([ + 'developer', + 'read-only', + ]); + }); + + it('paginates AppProject sync windows', () => { + const windows = [ + { kind: 'allow', schedule: '0 9 * * 1-5' }, + { kind: 'deny', schedule: '0 22 * * *' }, + { kind: 'allow', schedule: '0 12 * * 6' }, + ]; + + expect(paginateItems(windows, 1, 2)).toEqual([ + { kind: 'allow', schedule: '0 9 * * 1-5' }, + { kind: 'deny', schedule: '0 22 * * *' }, + ]); + expect(paginateItems(windows, 2, 2)).toEqual([{ kind: 'allow', schedule: '0 12 * * 6' }]); + }); + + it('paginates ImageUpdater recent updates newest-first display lists', () => { + const updates = [ + { alias: 'test-nginx', newVersion: '1.17.11' }, + { alias: 'test-memcached', newVersion: '1.6.12' }, + { alias: 'test-redis', newVersion: '7.2.0' }, + ]; + + expect(paginateItems(updates, 1, 2).map((update) => update.alias)).toEqual([ + 'test-nginx', + 'test-memcached', + ]); + expect(paginateItems(updates, 2, 2).map((update) => update.alias)).toEqual(['test-redis']); + }); + + it('paginates Application history after reversing for newest-first display', () => { + const history = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + // Default (no column sort): reverse source order, then paginate. + const displayHistory = [...history].reverse(); + + expect(paginateItems(displayHistory, 1, 2).map((entry) => entry.id)).toEqual([4, 3]); + expect(paginateItems(displayHistory, 2, 2).map((entry) => entry.id)).toEqual([2, 1]); + }); + + it('keeps an explicit sort order when paginating history (no post-sort reverse)', () => { + const sortedAsc = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + + expect(paginateItems(sortedAsc, 1, 2).map((entry) => entry.id)).toEqual([1, 2]); + expect(paginateItems(sortedAsc, 2, 2).map((entry) => entry.id)).toEqual([3, 4]); + }); + + it('yields no rows for empty detail-tab tables (no pager expected)', () => { + expect(paginateItems([], 1, GITOPS_DEFAULT_PER_PAGE)).toEqual([]); + expect(paginateItems(undefined, 1, GITOPS_DEFAULT_PER_PAGE)).toEqual([]); + }); });