Skip to content

Commit b5f13af

Browse files
Merge pull request #2217 from redpanda-data/jb/fix-pagination-over-50-items
frontend: fix pagination for lists exceeding page size
2 parents a687d5f + 91e7f29 commit b5f13af

10 files changed

Lines changed: 750 additions & 100 deletions

frontend/src/components/pages/secrets-store/secrets-store-list-page.test.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { createRouterTransport } from '@connectrpc/connect';
1414
import { ListSecretsResponseSchema } from 'protogen/redpanda/api/console/v1alpha1/secret_pb';
1515
import { listSecrets } from 'protogen/redpanda/api/console/v1alpha1/secret-SecretService_connectquery';
1616
import { Scope, SecretSchema } from 'protogen/redpanda/api/dataplane/v1/secret_pb';
17+
import { MAX_PAGE_SIZE } from 'react-query/react-query.utils';
1718
import { renderWithFileRoutes, screen, waitFor } from 'test-utils';
1819

1920
vi.mock('state/ui-state', () => ({
@@ -67,7 +68,7 @@ describe('SecretsStoreListPage', () => {
6768
const callArgs = listSecretsMock.mock.calls[0];
6869
expect(callArgs[0]).toMatchObject({
6970
request: {
70-
pageSize: 500,
71+
pageSize: MAX_PAGE_SIZE,
7172
},
7273
});
7374
});
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
/**
2+
* Copyright 2025 Redpanda Data, Inc.
3+
*
4+
* Use of this software is governed by the Business Source License
5+
* included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md
6+
*
7+
* As of the Change Date specified in that file, in accordance with
8+
* the Business Source License, use of this software will be governed
9+
* by the Apache License, Version 2.0
10+
*/
11+
12+
import { create } from '@bufbuild/protobuf';
13+
import { createRouterTransport } from '@connectrpc/connect';
14+
import { renderHook, waitFor } from '@testing-library/react';
15+
import { AIAgentSchema, ListAIAgentsResponseSchema } from 'protogen/redpanda/api/dataplane/v1alpha3/ai_agent_pb';
16+
import { listAIAgents } from 'protogen/redpanda/api/dataplane/v1alpha3/ai_agent-AIAgentService_connectquery';
17+
import { connectQueryWrapper } from 'test-utils';
18+
import { describe, expect, test } from 'vitest';
19+
20+
import { useListAIAgentsQuery } from './ai-agent';
21+
22+
describe('useListAIAgentsQuery', () => {
23+
test('fetches all pages and flattens agents into a single array', async () => {
24+
let callCount = 0;
25+
26+
const transport = createRouterTransport(({ rpc }) => {
27+
rpc(listAIAgents, (req) => {
28+
callCount += 1;
29+
const pageToken = req.pageToken;
30+
31+
if (pageToken === '') {
32+
return create(ListAIAgentsResponseSchema, {
33+
aiAgents: [create(AIAgentSchema, { id: 'agent-1', displayName: 'Agent 1' })],
34+
nextPageToken: 'page2',
35+
});
36+
}
37+
if (pageToken === 'page2') {
38+
return create(ListAIAgentsResponseSchema, {
39+
aiAgents: [create(AIAgentSchema, { id: 'agent-2', displayName: 'Agent 2' })],
40+
nextPageToken: 'page3',
41+
});
42+
}
43+
return create(ListAIAgentsResponseSchema, {
44+
aiAgents: [create(AIAgentSchema, { id: 'agent-3', displayName: 'Agent 3' })],
45+
nextPageToken: '',
46+
});
47+
});
48+
});
49+
50+
const { wrapper } = connectQueryWrapper({ defaultOptions: { queries: { retry: false } } }, transport);
51+
52+
const { result } = renderHook(() => useListAIAgentsQuery(), { wrapper });
53+
54+
await waitFor(() => {
55+
expect(result.current.isLoading).toBe(false);
56+
expect(result.current.data.aiAgents).toHaveLength(3);
57+
});
58+
59+
expect(callCount).toBe(3);
60+
expect(result.current.data.aiAgents.map((a) => a.id)).toEqual(['agent-1', 'agent-2', 'agent-3']);
61+
});
62+
63+
test('with page size 1, fetches once per record', async () => {
64+
let callCount = 0;
65+
66+
const transport = createRouterTransport(({ rpc }) => {
67+
rpc(listAIAgents, (req) => {
68+
callCount += 1;
69+
const pageToken = req.pageToken;
70+
71+
if (pageToken === '') {
72+
return create(ListAIAgentsResponseSchema, {
73+
aiAgents: [create(AIAgentSchema, { id: 'agent-1', displayName: 'Agent 1' })],
74+
nextPageToken: 'page2',
75+
});
76+
}
77+
// Last page
78+
return create(ListAIAgentsResponseSchema, {
79+
aiAgents: [create(AIAgentSchema, { id: 'agent-2', displayName: 'Agent 2' })],
80+
nextPageToken: '',
81+
});
82+
});
83+
});
84+
85+
const { wrapper } = connectQueryWrapper({ defaultOptions: { queries: { retry: false } } }, transport);
86+
87+
const { result } = renderHook(() => useListAIAgentsQuery(), { wrapper });
88+
89+
await waitFor(() => {
90+
expect(result.current.isLoading).toBe(false);
91+
expect(result.current.data.aiAgents).toHaveLength(2);
92+
});
93+
94+
// With page size 1 and 2 records, the query should execute exactly twice
95+
expect(callCount).toBe(2);
96+
expect(result.current.data.aiAgents.map((a) => a.id)).toEqual(['agent-1', 'agent-2']);
97+
});
98+
99+
test('returns all data in a single page when no nextPageToken', async () => {
100+
let callCount = 0;
101+
102+
const transport = createRouterTransport(({ rpc }) => {
103+
rpc(listAIAgents, () => {
104+
callCount += 1;
105+
return create(ListAIAgentsResponseSchema, {
106+
aiAgents: [
107+
create(AIAgentSchema, { id: 'agent-1', displayName: 'Agent 1' }),
108+
create(AIAgentSchema, { id: 'agent-2', displayName: 'Agent 2' }),
109+
],
110+
nextPageToken: '',
111+
});
112+
});
113+
});
114+
115+
const { wrapper } = connectQueryWrapper({ defaultOptions: { queries: { retry: false } } }, transport);
116+
117+
const { result } = renderHook(() => useListAIAgentsQuery(), { wrapper });
118+
119+
await waitFor(() => {
120+
expect(result.current.isLoading).toBe(false);
121+
expect(result.current.data.aiAgents).toHaveLength(2);
122+
});
123+
124+
expect(callCount).toBe(1);
125+
});
126+
127+
test('handles empty result', async () => {
128+
let callCount = 0;
129+
130+
const transport = createRouterTransport(({ rpc }) => {
131+
rpc(listAIAgents, () => {
132+
callCount += 1;
133+
return create(ListAIAgentsResponseSchema, {
134+
aiAgents: [],
135+
nextPageToken: '',
136+
});
137+
});
138+
});
139+
140+
const { wrapper } = connectQueryWrapper({ defaultOptions: { queries: { retry: false } } }, transport);
141+
142+
const { result } = renderHook(() => useListAIAgentsQuery(), { wrapper });
143+
144+
await waitFor(() => {
145+
expect(result.current.isLoading).toBe(false);
146+
});
147+
148+
expect(callCount).toBe(1);
149+
expect(result.current.data.aiAgents).toHaveLength(0);
150+
});
151+
});

frontend/src/react-query/api/ai-agent.tsx

Lines changed: 43 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,30 +23,53 @@ import {
2323
stopAIAgent,
2424
updateAIAgent,
2525
} from 'protogen/redpanda/api/dataplane/v1alpha3/ai_agent-AIAgentService_connectquery';
26-
import { type MessageInit, type QueryOptions, SHORT_POLLING_INTERVAL } from 'react-query/react-query.utils';
26+
import { useMemo } from 'react';
27+
import {
28+
MAX_PAGE_SIZE,
29+
type MessageInit,
30+
type QueryOptions,
31+
SHORT_POLLING_INTERVAL,
32+
} from 'react-query/react-query.utils';
33+
import { useInfiniteQueryWithAllPages } from 'react-query/use-infinite-query-with-all-pages';
2734
import { formatToastErrorMessageGRPC } from 'utils/toast.utils';
2835

29-
// TODO: Make this dynamic so that pagination can be used properly
30-
const AI_AGENT_MAX_PAGE_SIZE = 50;
31-
3236
export const useListAIAgentsQuery = (
3337
input?: MessageInit<ListAIAgentsRequest>,
3438
options?: QueryOptions<GenMessage<ListAIAgentsRequest>, ListAIAgentsResponse>
3539
) => {
36-
const listAIAgentsRequest = create(ListAIAgentsRequestSchema, {
37-
pageToken: '',
38-
pageSize: AI_AGENT_MAX_PAGE_SIZE,
39-
filter: input?.filter
40-
? create(ListAIAgentsRequest_FilterSchema, {
41-
nameContains: input.filter.nameContains,
42-
tags: input.filter.tags,
43-
})
44-
: undefined,
45-
});
40+
// Memoize request to prevent infinite re-renders
41+
const listAIAgentsRequest = useMemo(
42+
() =>
43+
create(ListAIAgentsRequestSchema, {
44+
pageToken: '',
45+
pageSize: MAX_PAGE_SIZE,
46+
filter: input?.filter
47+
? create(ListAIAgentsRequest_FilterSchema, {
48+
nameContains: input.filter.nameContains,
49+
tags: input.filter.tags,
50+
})
51+
: undefined,
52+
}) as ListAIAgentsRequest & Required<Pick<ListAIAgentsRequest, 'pageToken'>>,
53+
[input?.filter]
54+
);
4655

47-
return useQuery(listAIAgents, listAIAgentsRequest, {
56+
const listAIAgentsResult = useInfiniteQueryWithAllPages(listAIAgents, listAIAgentsRequest, {
4857
enabled: options?.enabled,
58+
getNextPageParam: (lastPage) => lastPage?.nextPageToken || undefined,
59+
pageParamKey: 'pageToken',
4960
});
61+
62+
const aiAgents = useMemo(() => {
63+
const allAiAgents = listAIAgentsResult?.data?.pages?.flatMap((response) => response?.aiAgents ?? []);
64+
return allAiAgents ?? [];
65+
}, [listAIAgentsResult.data]);
66+
67+
const data = useMemo(() => ({ aiAgents }), [aiAgents]);
68+
69+
return {
70+
...listAIAgentsResult,
71+
data,
72+
};
5073
};
5174

5275
export const useGetAIAgentQuery = (
@@ -93,7 +116,7 @@ export const useCreateAIAgentMutation = () => {
93116
await queryClient.invalidateQueries({
94117
queryKey: createConnectQueryKey({
95118
schema: AIAgentService.method.listAIAgents,
96-
cardinality: 'finite',
119+
cardinality: 'infinite',
97120
}),
98121
exact: false,
99122
});
@@ -115,7 +138,7 @@ export const useUpdateAIAgentMutation = () => {
115138
await queryClient.invalidateQueries({
116139
queryKey: createConnectQueryKey({
117140
schema: AIAgentService.method.listAIAgents,
118-
cardinality: 'finite',
141+
cardinality: 'infinite',
119142
}),
120143
exact: false,
121144
});
@@ -147,7 +170,7 @@ export const useDeleteAIAgentMutation = (options?: {
147170
await queryClient.invalidateQueries({
148171
queryKey: createConnectQueryKey({
149172
schema: AIAgentService.method.listAIAgents,
150-
cardinality: 'finite',
173+
cardinality: 'infinite',
151174
}),
152175
exact: false,
153176
});
@@ -174,7 +197,7 @@ export const useStopAIAgentMutation = () => {
174197
await queryClient.invalidateQueries({
175198
queryKey: createConnectQueryKey({
176199
schema: AIAgentService.method.listAIAgents,
177-
cardinality: 'finite',
200+
cardinality: 'infinite',
178201
}),
179202
exact: false,
180203
});
@@ -203,7 +226,7 @@ export const useStartAIAgentMutation = () => {
203226
await queryClient.invalidateQueries({
204227
queryKey: createConnectQueryKey({
205228
schema: AIAgentService.method.listAIAgents,
206-
cardinality: 'finite',
229+
cardinality: 'infinite',
207230
}),
208231
exact: false,
209232
});

0 commit comments

Comments
 (0)