Move pagination to the server side (#166)

This commit is contained in:
Matt Rubens 2025-07-01 14:22:36 -04:00 committed by GitHub
parent 9689177a70
commit 9a94ad70bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 202 additions and 18 deletions

View file

@ -0,0 +1,157 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getTasks } from '../events';
// Mock the analytics module
vi.mock('@/lib/server', () => ({
analytics: {
query: vi.fn(),
},
}));
// Mock the auth module
vi.mock('@/actions/auth', () => ({
authorizeAnalytics: vi.fn().mockResolvedValue({
effectiveUserId: 'test-user-id',
}),
}));
// Mock the db module
vi.mock('@roo-code-cloud/db/server', () => ({
getUsersById: vi.fn().mockResolvedValue({
'test-user-id': {
id: 'test-user-id',
name: 'Test User',
email: 'test@example.com',
},
}),
}));
describe('getTasks with filters', () => {
let mockQuery: ReturnType<typeof vi.fn>;
beforeEach(async () => {
vi.clearAllMocks();
const { analytics } = await import('@/lib/server');
mockQuery = vi.mocked(analytics.query);
mockQuery.mockResolvedValue({
json: () =>
Promise.resolve([
{
taskId: 'task-1',
userId: 'test-user-id',
provider: 'openai',
model: 'gpt-4',
mode: 'code',
completed: true,
tokens: 1000,
cost: 0.02,
timestamp: 1640995200,
title: 'Test Task',
repositoryUrl: 'https://github.com/test/repo',
repositoryName: 'test-repo',
defaultBranch: 'main',
},
]),
});
});
it('should include userId filter in query parameters', async () => {
await getTasks({
orgId: 'test-org',
filterType: 'userId',
filterValue: 'filter-user-id',
limit: 20,
});
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
query_params: expect.objectContaining({
filterUserId: 'filter-user-id',
}),
}),
);
const queryCall = mockQuery.mock.calls[0]?.[0];
expect(queryCall?.query).toContain('AND e.userId = {filterUserId: String}');
});
it('should include model filter in query parameters', async () => {
await getTasks({
orgId: 'test-org',
filterType: 'model',
filterValue: 'gpt-4',
limit: 20,
});
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
query_params: expect.objectContaining({
filterModel: 'gpt-4',
}),
}),
);
const queryCall = mockQuery.mock.calls[0]?.[0];
expect(queryCall?.query).toContain('AND e.modelId = {filterModel: String}');
});
it('should include repository filter in query parameters', async () => {
await getTasks({
orgId: 'test-org',
filterType: 'repositoryName',
filterValue: 'test-repo',
limit: 20,
});
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
query_params: expect.objectContaining({
filterRepository: 'test-repo',
}),
}),
);
const queryCall = mockQuery.mock.calls[0]?.[0];
expect(queryCall?.query).toContain(
'AND e.repositoryName = {filterRepository: String}',
);
});
it('should not include filter clauses when no filter is provided', async () => {
await getTasks({
orgId: 'test-org',
limit: 20,
});
const queryCall = mockQuery.mock.calls[0]?.[0];
expect(queryCall?.query).not.toContain('filterUserId');
expect(queryCall?.query).not.toContain('filterModel');
expect(queryCall?.query).not.toContain('filterRepository');
});
it('should return properly formatted tasks with user data', async () => {
const result = await getTasks({
orgId: 'test-org',
filterType: 'model',
filterValue: 'gpt-4',
limit: 20,
});
expect(result).toEqual({
tasks: [
expect.objectContaining({
taskId: 'task-1',
userId: 'test-user-id',
model: 'gpt-4',
user: expect.objectContaining({
id: 'test-user-id',
name: 'Test User',
email: 'test@example.com',
}),
}),
],
hasMore: false,
nextCursor: undefined,
});
});
});

View file

@ -409,6 +409,8 @@ export const getTasks = async ({
skipAuth = false,
limit = 20,
cursor,
filterType,
filterValue,
}: {
orgId?: string | null;
userId?: string | null;
@ -417,6 +419,8 @@ export const getTasks = async ({
skipAuth?: boolean;
limit?: number;
cursor?: number;
filterType?: 'userId' | 'model' | 'repositoryName';
filterValue?: string;
}): Promise<TasksResult> => {
let effectiveUserId = userId;
@ -476,6 +480,28 @@ export const getTasks = async ({
queryParams.cursor = cursor;
}
// Add filter parameters
if (filterType && filterValue) {
if (filterType === 'userId') {
queryParams.filterUserId = filterValue;
} else if (filterType === 'model') {
queryParams.filterModel = filterValue;
} else if (filterType === 'repositoryName') {
queryParams.filterRepository = filterValue;
}
}
// Build filter conditions
const filterConditions = [];
if (filterType === 'userId' && filterValue) {
filterConditions.push('AND e.userId = {filterUserId: String}');
} else if (filterType === 'model' && filterValue) {
filterConditions.push('AND e.modelId = {filterModel: String}');
} else if (filterType === 'repositoryName' && filterValue) {
filterConditions.push('AND e.repositoryName = {filterRepository: String}');
}
const filterClause = filterConditions.join(' ');
// TODO: Handle same-timestamp edge cases
// Currently using only timestamp as cursor, but this can miss/duplicate tasks
// if multiple tasks have the same timestamp at page boundaries.
@ -519,6 +545,7 @@ export const getTasks = async ({
AND e.modelId IS NOT NULL
${userFilter}
${taskFilter}
${filterClause}
GROUP BY 1, 2
${havingFilter}
ORDER BY timestamp DESC

View file

@ -1,4 +1,4 @@
import { useMemo, useEffect, useRef } from 'react';
import { useEffect, useRef } from 'react';
import { useAuth } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
@ -40,6 +40,8 @@ export const Tasks = ({
!orgId,
pagination.currentCursor,
pagination.pageSize,
filter?.type,
filter?.value,
],
queryFn: () =>
getTasks({
@ -47,6 +49,8 @@ export const Tasks = ({
userId: userRole === 'member' ? currentUserId : undefined,
limit: pagination.pageSize,
cursor: pagination.currentCursor,
filterType: filter?.type,
filterValue: filter?.value,
}),
enabled: true, // Run for both personal and organization context
...polling,
@ -59,26 +63,22 @@ export const Tasks = ({
}
}, [data?.nextCursor, pagination]);
// Note: The pagination hook automatically handles total updates via the pagination controls
// Reset pagination when filter changes
const prevFilter = useRef<Filter | null>(null);
useEffect(() => {
const currentFilter = filter;
const hasFilterChanged =
prevFilter.current?.type !== currentFilter?.type ||
prevFilter.current?.value !== currentFilter?.value;
const tasks = useMemo(() => {
const allTasks = data?.tasks || [];
if (!filter) {
return allTasks;
if (hasFilterChanged) {
pagination.reset();
prevFilter.current = currentFilter;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filter]);
return allTasks.filter((task) => {
if (filter.type === 'userId') {
return task.userId === filter.value;
} else if (filter.type === 'model') {
return task.model === filter.value;
} else if (filter.type === 'repositoryName') {
return task.repositoryName === filter.value;
}
return false;
});
}, [filter, data?.tasks]);
const tasks = data?.tasks || [];
if (isPending) {
return (