Skip to content

Commit 9a94ad7

Browse files
authored
Move pagination to the server side (#166)
1 parent 9689177 commit 9a94ad7

File tree

3 files changed

+202
-18
lines changed

3 files changed

+202
-18
lines changed
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { getTasks } from '../events';
3+
4+
// Mock the analytics module
5+
vi.mock('@/lib/server', () => ({
6+
analytics: {
7+
query: vi.fn(),
8+
},
9+
}));
10+
11+
// Mock the auth module
12+
vi.mock('@/actions/auth', () => ({
13+
authorizeAnalytics: vi.fn().mockResolvedValue({
14+
effectiveUserId: 'test-user-id',
15+
}),
16+
}));
17+
18+
// Mock the db module
19+
vi.mock('@roo-code-cloud/db/server', () => ({
20+
getUsersById: vi.fn().mockResolvedValue({
21+
'test-user-id': {
22+
id: 'test-user-id',
23+
name: 'Test User',
24+
25+
},
26+
}),
27+
}));
28+
29+
describe('getTasks with filters', () => {
30+
let mockQuery: ReturnType<typeof vi.fn>;
31+
32+
beforeEach(async () => {
33+
vi.clearAllMocks();
34+
const { analytics } = await import('@/lib/server');
35+
mockQuery = vi.mocked(analytics.query);
36+
mockQuery.mockResolvedValue({
37+
json: () =>
38+
Promise.resolve([
39+
{
40+
taskId: 'task-1',
41+
userId: 'test-user-id',
42+
provider: 'openai',
43+
model: 'gpt-4',
44+
mode: 'code',
45+
completed: true,
46+
tokens: 1000,
47+
cost: 0.02,
48+
timestamp: 1640995200,
49+
title: 'Test Task',
50+
repositoryUrl: 'https://github.com/test/repo',
51+
repositoryName: 'test-repo',
52+
defaultBranch: 'main',
53+
},
54+
]),
55+
});
56+
});
57+
58+
it('should include userId filter in query parameters', async () => {
59+
await getTasks({
60+
orgId: 'test-org',
61+
filterType: 'userId',
62+
filterValue: 'filter-user-id',
63+
limit: 20,
64+
});
65+
66+
expect(mockQuery).toHaveBeenCalledWith(
67+
expect.objectContaining({
68+
query_params: expect.objectContaining({
69+
filterUserId: 'filter-user-id',
70+
}),
71+
}),
72+
);
73+
74+
const queryCall = mockQuery.mock.calls[0]?.[0];
75+
expect(queryCall?.query).toContain('AND e.userId = {filterUserId: String}');
76+
});
77+
78+
it('should include model filter in query parameters', async () => {
79+
await getTasks({
80+
orgId: 'test-org',
81+
filterType: 'model',
82+
filterValue: 'gpt-4',
83+
limit: 20,
84+
});
85+
86+
expect(mockQuery).toHaveBeenCalledWith(
87+
expect.objectContaining({
88+
query_params: expect.objectContaining({
89+
filterModel: 'gpt-4',
90+
}),
91+
}),
92+
);
93+
94+
const queryCall = mockQuery.mock.calls[0]?.[0];
95+
expect(queryCall?.query).toContain('AND e.modelId = {filterModel: String}');
96+
});
97+
98+
it('should include repository filter in query parameters', async () => {
99+
await getTasks({
100+
orgId: 'test-org',
101+
filterType: 'repositoryName',
102+
filterValue: 'test-repo',
103+
limit: 20,
104+
});
105+
106+
expect(mockQuery).toHaveBeenCalledWith(
107+
expect.objectContaining({
108+
query_params: expect.objectContaining({
109+
filterRepository: 'test-repo',
110+
}),
111+
}),
112+
);
113+
114+
const queryCall = mockQuery.mock.calls[0]?.[0];
115+
expect(queryCall?.query).toContain(
116+
'AND e.repositoryName = {filterRepository: String}',
117+
);
118+
});
119+
120+
it('should not include filter clauses when no filter is provided', async () => {
121+
await getTasks({
122+
orgId: 'test-org',
123+
limit: 20,
124+
});
125+
126+
const queryCall = mockQuery.mock.calls[0]?.[0];
127+
expect(queryCall?.query).not.toContain('filterUserId');
128+
expect(queryCall?.query).not.toContain('filterModel');
129+
expect(queryCall?.query).not.toContain('filterRepository');
130+
});
131+
132+
it('should return properly formatted tasks with user data', async () => {
133+
const result = await getTasks({
134+
orgId: 'test-org',
135+
filterType: 'model',
136+
filterValue: 'gpt-4',
137+
limit: 20,
138+
});
139+
140+
expect(result).toEqual({
141+
tasks: [
142+
expect.objectContaining({
143+
taskId: 'task-1',
144+
userId: 'test-user-id',
145+
model: 'gpt-4',
146+
user: expect.objectContaining({
147+
id: 'test-user-id',
148+
name: 'Test User',
149+
150+
}),
151+
}),
152+
],
153+
hasMore: false,
154+
nextCursor: undefined,
155+
});
156+
});
157+
});

apps/web/src/actions/analytics/events.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -409,6 +409,8 @@ export const getTasks = async ({
409409
skipAuth = false,
410410
limit = 20,
411411
cursor,
412+
filterType,
413+
filterValue,
412414
}: {
413415
orgId?: string | null;
414416
userId?: string | null;
@@ -417,6 +419,8 @@ export const getTasks = async ({
417419
skipAuth?: boolean;
418420
limit?: number;
419421
cursor?: number;
422+
filterType?: 'userId' | 'model' | 'repositoryName';
423+
filterValue?: string;
420424
}): Promise<TasksResult> => {
421425
let effectiveUserId = userId;
422426

@@ -476,6 +480,28 @@ export const getTasks = async ({
476480
queryParams.cursor = cursor;
477481
}
478482

483+
// Add filter parameters
484+
if (filterType && filterValue) {
485+
if (filterType === 'userId') {
486+
queryParams.filterUserId = filterValue;
487+
} else if (filterType === 'model') {
488+
queryParams.filterModel = filterValue;
489+
} else if (filterType === 'repositoryName') {
490+
queryParams.filterRepository = filterValue;
491+
}
492+
}
493+
494+
// Build filter conditions
495+
const filterConditions = [];
496+
if (filterType === 'userId' && filterValue) {
497+
filterConditions.push('AND e.userId = {filterUserId: String}');
498+
} else if (filterType === 'model' && filterValue) {
499+
filterConditions.push('AND e.modelId = {filterModel: String}');
500+
} else if (filterType === 'repositoryName' && filterValue) {
501+
filterConditions.push('AND e.repositoryName = {filterRepository: String}');
502+
}
503+
const filterClause = filterConditions.join(' ');
504+
479505
// TODO: Handle same-timestamp edge cases
480506
// Currently using only timestamp as cursor, but this can miss/duplicate tasks
481507
// if multiple tasks have the same timestamp at page boundaries.
@@ -519,6 +545,7 @@ export const getTasks = async ({
519545
AND e.modelId IS NOT NULL
520546
${userFilter}
521547
${taskFilter}
548+
${filterClause}
522549
GROUP BY 1, 2
523550
${havingFilter}
524551
ORDER BY timestamp DESC

apps/web/src/app/(authenticated)/usage/Tasks.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useMemo, useEffect, useRef } from 'react';
1+
import { useEffect, useRef } from 'react';
22
import { useAuth } from '@clerk/nextjs';
33
import { useQuery } from '@tanstack/react-query';
44

@@ -40,13 +40,17 @@ export const Tasks = ({
4040
!orgId,
4141
pagination.currentCursor,
4242
pagination.pageSize,
43+
filter?.type,
44+
filter?.value,
4345
],
4446
queryFn: () =>
4547
getTasks({
4648
orgId,
4749
userId: userRole === 'member' ? currentUserId : undefined,
4850
limit: pagination.pageSize,
4951
cursor: pagination.currentCursor,
52+
filterType: filter?.type,
53+
filterValue: filter?.value,
5054
}),
5155
enabled: true, // Run for both personal and organization context
5256
...polling,
@@ -59,26 +63,22 @@ export const Tasks = ({
5963
}
6064
}, [data?.nextCursor, pagination]);
6165

62-
// Note: The pagination hook automatically handles total updates via the pagination controls
63-
64-
const tasks = useMemo(() => {
65-
const allTasks = data?.tasks || [];
66+
// Reset pagination when filter changes
67+
const prevFilter = useRef<Filter | null>(null);
68+
useEffect(() => {
69+
const currentFilter = filter;
70+
const hasFilterChanged =
71+
prevFilter.current?.type !== currentFilter?.type ||
72+
prevFilter.current?.value !== currentFilter?.value;
6673

67-
if (!filter) {
68-
return allTasks;
74+
if (hasFilterChanged) {
75+
pagination.reset();
76+
prevFilter.current = currentFilter;
6977
}
78+
// eslint-disable-next-line react-hooks/exhaustive-deps
79+
}, [filter]);
7080

71-
return allTasks.filter((task) => {
72-
if (filter.type === 'userId') {
73-
return task.userId === filter.value;
74-
} else if (filter.type === 'model') {
75-
return task.model === filter.value;
76-
} else if (filter.type === 'repositoryName') {
77-
return task.repositoryName === filter.value;
78-
}
79-
return false;
80-
});
81-
}, [filter, data?.tasks]);
81+
const tasks = data?.tasks || [];
8282

8383
if (isPending) {
8484
return (

0 commit comments

Comments
 (0)