Skip to content

Commit b890242

Browse files
committed
feat: add pagination to list pages
Signed-off-by: Atif Ali <atali@redhat.com>
1 parent 89bc2b8 commit b890242

10 files changed

Lines changed: 304 additions & 8 deletions

locales/en/plugin__gitops-plugin.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,5 +356,13 @@
356356
"annotations": "annotations",
357357
"annotation": "annotation",
358358
"No owner": "No owner",
359-
"Tech preview": "Tech preview"
359+
"Tech preview": "Tech preview",
360+
"Pagination": "Pagination",
361+
"Go to first page": "Go to first page",
362+
"Go to previous page": "Go to previous page",
363+
"Go to next page": "Go to next page",
364+
"Go to last page": "Go to last page",
365+
"Items per page": "Items per page",
366+
"per page": "per page",
367+
"of": "of"
360368
}

locales/ja/plugin__gitops-plugin.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,5 +356,13 @@
356356
"annotations": "annotations",
357357
"annotation": "annotation",
358358
"No owner": "No owner",
359-
"Tech preview": "Tech preview"
359+
"Tech preview": "Tech preview",
360+
"Pagination": "Pagination",
361+
"Go to first page": "Go to first page",
362+
"Go to previous page": "Go to previous page",
363+
"Go to next page": "Go to next page",
364+
"Go to last page": "Go to last page",
365+
"Items per page": "Items per page",
366+
"per page": "per page",
367+
"of": "of"
360368
}

locales/ko/plugin__gitops-plugin.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,5 +356,13 @@
356356
"annotations": "annotations",
357357
"annotation": "annotation",
358358
"No owner": "No owner",
359-
"Tech preview": "Tech preview"
359+
"Tech preview": "Tech preview",
360+
"Pagination": "Pagination",
361+
"Go to first page": "Go to first page",
362+
"Go to previous page": "Go to previous page",
363+
"Go to next page": "Go to next page",
364+
"Go to last page": "Go to last page",
365+
"Items per page": "Items per page",
366+
"per page": "per page",
367+
"of": "of"
360368
}

locales/zh/plugin__gitops-plugin.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -356,5 +356,13 @@
356356
"annotations": "annotations",
357357
"annotation": "annotation",
358358
"No owner": "No owner",
359-
"Tech preview": "Tech preview"
359+
"Tech preview": "Tech preview",
360+
"Pagination": "Pagination",
361+
"Go to first page": "Go to first page",
362+
"Go to previous page": "Go to previous page",
363+
"Go to next page": "Go to next page",
364+
"Go to last page": "Go to last page",
365+
"Items per page": "Items per page",
366+
"per page": "per page",
367+
"of": "of"
360368
}

src/gitops/components/shared/ApplicationList.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@ import {
4343
useShowOperandsInAllNamespaces,
4444
} from './AllNamespaces';
4545
import ApplicationSetApplicationsView from './ApplicationSetApplicationsView';
46-
import { GitOpsDataViewTable, useGitOpsDataViewSort } from './DataView';
46+
import {
47+
getGitOpsPaginationResetKey,
48+
GitOpsDataViewTable,
49+
paginateItems,
50+
useGitOpsDataViewPagination,
51+
useGitOpsDataViewSort,
52+
} from './DataView';
4753

4854
interface ApplicationProps {
4955
namespace: string;
@@ -149,7 +155,21 @@ const ApplicationList: React.FC<ApplicationProps> = ({
149155
});
150156
});
151157
}, [filteredData, searchQuery]);
152-
const rows = useApplicationRowsDV(filteredBySearch, namespace);
158+
159+
const searchParamsKey = searchParams.toString();
160+
const paginationResetKey = React.useMemo(
161+
() => getGitOpsPaginationResetKey(namespace, new URLSearchParams(searchParamsKey)),
162+
[namespace, searchParamsKey],
163+
);
164+
const pagination = useGitOpsDataViewPagination({
165+
itemCount: filteredBySearch.length,
166+
resetKey: paginationResetKey,
167+
});
168+
const pagedApplications = React.useMemo(
169+
() => paginateItems(filteredBySearch, pagination.page, pagination.perPage),
170+
[filteredBySearch, pagination.page, pagination.perPage],
171+
);
172+
const rows = useApplicationRowsDV(pagedApplications, namespace);
153173

154174
// Check if there are applications owned by this ApplicationSet initially (before filters/search)
155175
const hasOwnedApplications = ownedApps.length > 0;
@@ -269,6 +289,8 @@ const ApplicationList: React.FC<ApplicationProps> = ({
269289
emptyState={empty}
270290
errorState={error || undefined}
271291
isError={!!loadError}
292+
itemCount={filteredBySearch.length}
293+
pagination={pagination}
272294
/>
273295
)}
274296
{!appset && (
@@ -279,6 +301,8 @@ const ApplicationList: React.FC<ApplicationProps> = ({
279301
emptyState={empty}
280302
errorState={error || undefined}
281303
isError={!!loadError}
304+
itemCount={filteredBySearch.length}
305+
pagination={pagination}
282306
/>
283307
)}
284308
</ListPageBody>

src/gitops/components/shared/ApplicationSetApplicationsView.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { DataViewTh, DataViewTr } from '@patternfly/react-data-view/dist/esm/Dat
99

1010
import { ApplicationSetGraphView } from '../appset/graph/ApplicationSetGraphView';
1111

12-
import { GitOpsDataViewTable } from './DataView';
12+
import { type GitOpsDataViewPagination, GitOpsDataViewTable } from './DataView';
1313
import GitOpsViewSwitcher from './GitOpsViewSwitcher';
1414
import { APPLICATION_SET_APPLICATIONS_VIEW_SETTING_KEY, GitOpsViewType } from './GitOpsViewType';
1515

@@ -31,6 +31,8 @@ type ApplicationSetApplicationsViewProps = {
3131
errorState?: React.ReactNode;
3232
isError?: boolean;
3333
isEmpty: boolean;
34+
itemCount?: number;
35+
pagination?: GitOpsDataViewPagination;
3436
};
3537

3638
const ApplicationSetApplicationsView: React.FC<ApplicationSetApplicationsViewProps> = ({
@@ -49,6 +51,8 @@ const ApplicationSetApplicationsView: React.FC<ApplicationSetApplicationsViewPro
4951
errorState,
5052
isError,
5153
isEmpty,
54+
itemCount,
55+
pagination,
5256
}) => {
5357
const [savedViewType, setSavedViewType, viewSettingsLoaded] = useUserSettings<GitOpsViewType>(
5458
APPLICATION_SET_APPLICATIONS_VIEW_SETTING_KEY,
@@ -124,6 +128,8 @@ const ApplicationSetApplicationsView: React.FC<ApplicationSetApplicationsViewPro
124128
emptyState={emptyState}
125129
errorState={errorState}
126130
isError={isError}
131+
itemCount={itemCount}
132+
pagination={pagination}
127133
activeState={
128134
// eslint-disable-next-line no-nested-ternary
129135
isError

src/gitops/components/shared/DataView/GitOpsDataViewTable.tsx

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,30 @@
11
import * as React from 'react';
22
import { useSearchParams } from 'react-router-dom-v5-compat';
33

4+
import { useGitOpsTranslation } from '@gitops/utils/hooks/useGitOpsTranslation';
5+
import { Pagination, PaginationVariant } from '@patternfly/react-core';
46
import DataView, { DataViewState } from '@patternfly/react-data-view/dist/esm/DataView';
57
import DataViewTable, {
68
DataViewTh,
79
DataViewTr,
810
} from '@patternfly/react-data-view/dist/esm/DataViewTable';
9-
import { useDataViewSort } from '@patternfly/react-data-view/dist/esm/Hooks';
11+
import DataViewToolbar from '@patternfly/react-data-view/dist/esm/DataViewToolbar';
12+
import { useDataViewPagination, useDataViewSort } from '@patternfly/react-data-view/dist/esm/Hooks';
1013
import { ThProps } from '@patternfly/react-table';
1114

15+
import { GITOPS_DEFAULT_PER_PAGE, GITOPS_PER_PAGE_OPTIONS } from './gitOpsDataViewPagination';
16+
17+
let gitOpsPaginationInstanceCounter = 0;
18+
19+
const useGitOpsPaginationWidgetIdBase = (): string => {
20+
const idRef = React.useRef<string>();
21+
if (!idRef.current) {
22+
gitOpsPaginationInstanceCounter += 1;
23+
idRef.current = `gitops-pagination-${gitOpsPaginationInstanceCounter}`;
24+
}
25+
return idRef.current;
26+
};
27+
1228
type BodyStateKey = 'empty' | 'error';
1329

1430
export type GitOpsDataViewBodyStates = Partial<Record<BodyStateKey, React.ReactNode>>;
@@ -46,6 +62,28 @@ export type GitOpsDataViewTableProps = {
4662
* the appropriate state based on isEmpty and isError.
4763
*/
4864
activeState?: DataViewState | null;
65+
/**
66+
* Total number of filtered items. Required when pagination is enabled so the
67+
* pager can show "1-50 of N" against the full result set, not the current page.
68+
*/
69+
itemCount?: number;
70+
/**
71+
* Optional PF pagination state. When omitted, the table renders every row.
72+
*/
73+
pagination?: GitOpsDataViewPagination;
74+
};
75+
76+
export type GitOpsDataViewPagination = {
77+
page: number;
78+
perPage: number;
79+
onSetPage: (
80+
event: React.MouseEvent | React.KeyboardEvent | MouseEvent | undefined,
81+
newPage: number,
82+
) => void;
83+
onPerPageSelect: (
84+
event: React.MouseEvent | React.KeyboardEvent | MouseEvent | undefined,
85+
newPerPage: number,
86+
) => void;
4987
};
5088

5189
const mergeBodyStates = (
@@ -73,7 +111,10 @@ export const GitOpsDataViewTable: React.FC<GitOpsDataViewTableProps> = ({
73111
errorState,
74112
bodyStates,
75113
activeState,
114+
itemCount,
115+
pagination,
76116
}) => {
117+
const paginationWidgetIdBase = useGitOpsPaginationWidgetIdBase();
77118
const resolvedBodyStates = React.useMemo(
78119
() =>
79120
mergeBodyStates(bodyStates, {
@@ -99,13 +140,69 @@ export const GitOpsDataViewTable: React.FC<GitOpsDataViewTableProps> = ({
99140
return null;
100141
}, [activeState, isEmpty, isError, isLoading]);
101142

143+
const paginationItemCount = itemCount ?? 0;
144+
const showPagination = !!pagination && paginationItemCount > 0 && !isError && !isLoading;
145+
102146
return (
103147
<DataView activeState={resolvedActiveState}>
148+
{showPagination && pagination && (
149+
<DataViewToolbar
150+
pagination={
151+
<GitOpsPagination
152+
itemCount={paginationItemCount}
153+
pagination={pagination}
154+
variant={PaginationVariant.top}
155+
widgetId={`${paginationWidgetIdBase}-top`}
156+
/>
157+
}
158+
/>
159+
)}
104160
<DataViewTable columns={columns} rows={rows} bodyStates={resolvedBodyStates} />
161+
{showPagination && pagination && (
162+
<DataViewToolbar
163+
pagination={
164+
<GitOpsPagination
165+
itemCount={paginationItemCount}
166+
pagination={pagination}
167+
variant={PaginationVariant.bottom}
168+
widgetId={`${paginationWidgetIdBase}-bottom`}
169+
/>
170+
}
171+
/>
172+
)}
105173
</DataView>
106174
);
107175
};
108176

177+
const GitOpsPagination: React.FC<{
178+
itemCount: number;
179+
pagination: GitOpsDataViewPagination;
180+
variant: PaginationVariant;
181+
widgetId: string;
182+
}> = ({ itemCount, pagination, variant, widgetId }) => {
183+
const { t } = useGitOpsTranslation();
184+
185+
return (
186+
<Pagination
187+
itemCount={itemCount}
188+
perPageOptions={GITOPS_PER_PAGE_OPTIONS}
189+
variant={variant}
190+
widgetId={widgetId}
191+
titles={{
192+
paginationAriaLabel: t('Pagination'),
193+
toFirstPageAriaLabel: t('Go to first page'),
194+
toPreviousPageAriaLabel: t('Go to previous page'),
195+
toNextPageAriaLabel: t('Go to next page'),
196+
toLastPageAriaLabel: t('Go to last page'),
197+
itemsPerPage: t('Items per page'),
198+
perPageSuffix: t('per page'),
199+
ofWord: t('of'),
200+
}}
201+
{...pagination}
202+
/>
203+
);
204+
};
205+
109206
export interface GitOpsDataViewSortConfig {
110207
key: string;
111208
}
@@ -169,4 +266,59 @@ export const useGitOpsDataViewSort = (
169266
};
170267
};
171268

269+
/**
270+
* Client-side pagination for GitOps DataView tables. Matches Console: 10/20/50/100,
271+
* default 50, page and perPage stored in the URL. Resets to page 1 when resetKey changes
272+
* (filters, search, namespace) and clamps when the result set shrinks.
273+
*/
274+
export const useGitOpsDataViewPagination = ({
275+
itemCount,
276+
resetKey,
277+
}: {
278+
itemCount: number;
279+
resetKey?: string;
280+
}): GitOpsDataViewPagination => {
281+
const [searchParams, setSearchParams] = useSearchParams();
282+
283+
const setMergedSearchParams = React.useCallback(
284+
(params: URLSearchParams) => {
285+
setSearchParams((prev) => {
286+
const next = new URLSearchParams(prev);
287+
params.forEach((value, key) => {
288+
next.set(key, value);
289+
});
290+
return next;
291+
});
292+
},
293+
[setSearchParams],
294+
);
295+
296+
const pagination = useDataViewPagination({
297+
perPage: GITOPS_DEFAULT_PER_PAGE,
298+
searchParams,
299+
setSearchParams: setMergedSearchParams,
300+
});
301+
const { page, perPage, onSetPage } = pagination;
302+
const previousResetKey = React.useRef(resetKey);
303+
304+
React.useEffect(() => {
305+
if (resetKey === undefined || previousResetKey.current === resetKey) {
306+
return;
307+
}
308+
previousResetKey.current = resetKey;
309+
if (page > 1) {
310+
onSetPage(undefined, 1);
311+
}
312+
}, [onSetPage, page, resetKey]);
313+
314+
React.useEffect(() => {
315+
const maxPage = Math.max(1, Math.ceil(itemCount / perPage) || 1);
316+
if (page > maxPage) {
317+
onSetPage(undefined, maxPage);
318+
}
319+
}, [itemCount, onSetPage, page, perPage]);
320+
321+
return pagination;
322+
};
323+
172324
type GitOpsSetSearchParams = ReturnType<typeof useSearchParams>[1];
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { getGitOpsPaginationResetKey, paginateItems } from './gitOpsDataViewPagination';
2+
3+
describe('paginateItems', () => {
4+
const items = ['a', 'b', 'c', 'd', 'e'];
5+
6+
it('returns the first page', () => {
7+
expect(paginateItems(items, 1, 2)).toEqual(['a', 'b']);
8+
});
9+
10+
it('returns a middle page', () => {
11+
expect(paginateItems(items, 2, 2)).toEqual(['c', 'd']);
12+
});
13+
14+
it('returns a partial last page', () => {
15+
expect(paginateItems(items, 3, 2)).toEqual(['e']);
16+
});
17+
18+
it('treats missing items as an empty list', () => {
19+
expect(paginateItems(undefined, 1, 50)).toEqual([]);
20+
});
21+
});
22+
23+
describe('getGitOpsPaginationResetKey', () => {
24+
it('ignores pagination and sort URL params', () => {
25+
const params = new URLSearchParams(
26+
'page=3&perPage=20&sortBy=name&direction=asc&q=guestbook&rowFilter-app-sync=Synced',
27+
);
28+
29+
expect(getGitOpsPaginationResetKey('argocd', params)).toBe(
30+
'argocd|q=guestbook&rowFilter-app-sync=Synced',
31+
);
32+
});
33+
34+
it('changes when namespace or filters change', () => {
35+
const params = new URLSearchParams('q=guestbook');
36+
37+
expect(getGitOpsPaginationResetKey('argocd', params)).not.toBe(
38+
getGitOpsPaginationResetKey('openshift-gitops', params),
39+
);
40+
expect(getGitOpsPaginationResetKey('argocd', params)).not.toBe(
41+
getGitOpsPaginationResetKey('argocd', new URLSearchParams('q=other')),
42+
);
43+
});
44+
});

0 commit comments

Comments
 (0)