Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions frontend/__tests__/AgentsPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ const mockStats: AgentStats = {
totalVolumeStroops: '10000000',
};

function makeAgent(overrides: Partial<AgentEntry> = {}): AgentEntry {
return {
...mockAgent,
...overrides,
address: overrides.address ?? mockAgent.address,
name: overrides.name ?? mockAgent.name,
score: overrides.score ?? mockAgent.score,
total_payments: overrides.total_payments ?? mockAgent.total_payments,
successful_payments: overrides.successful_payments ?? mockAgent.successful_payments,
failed_payments: overrides.failed_payments ?? mockAgent.failed_payments,
};
}

describe('AgentsPage retry state', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand All @@ -81,3 +94,134 @@ describe('AgentsPage retry state', () => {
expect(screen.queryByText('Network disconnected')).not.toBeInTheDocument();
});
});

describe('AgentsPage score tier filter', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('defaults to the All filter and shows all agents', async () => {
(fetchAgents as jest.Mock).mockResolvedValue({
agents: [
makeAgent({ address: 'A', name: 'New Agent', score: 100, total_payments: '1' }),
makeAgent({ address: 'B', name: 'Building Agent', score: 350, total_payments: '2' }),
makeAgent({ address: 'C', name: 'Established Agent', score: 650, total_payments: '3' }),
makeAgent({ address: 'D', name: 'Trusted Agent', score: 950, total_payments: '4' }),
makeAgent({ address: 'E', name: 'Elite Agent', score: 1000, total_payments: '5' }),
],
total: 5,
page: 0,
pageSize: PAGE_SIZE,
});
(fetchAgentStats as jest.Mock).mockResolvedValue(mockStats);

renderPage();

await waitFor(() => {
expect(screen.getByText('New Agent')).toBeInTheDocument();
expect(screen.getByText('Elite Agent')).toBeInTheDocument();
});

expect(screen.getByRole('button', { name: 'All' })).toHaveAttribute('aria-pressed', 'true');
});

it('filters agents by each score tier', async () => {
(fetchAgents as jest.Mock).mockResolvedValue({
agents: [
makeAgent({ address: 'A', name: 'New Agent', score: 100, total_payments: '1' }),
makeAgent({ address: 'B', name: 'Building Agent', score: 350, total_payments: '2' }),
makeAgent({ address: 'C', name: 'Established Agent', score: 650, total_payments: '3' }),
makeAgent({ address: 'D', name: 'Trusted Agent', score: 950, total_payments: '4' }),
makeAgent({ address: 'E', name: 'Elite Agent', score: 1000, total_payments: '5' }),
],
total: 5,
page: 0,
pageSize: PAGE_SIZE,
});
(fetchAgentStats as jest.Mock).mockResolvedValue(mockStats);

renderPage();

await waitFor(() => {
expect(screen.getByText('Trusted Agent')).toBeInTheDocument();
});

fireEvent.click(screen.getByRole('button', { name: 'Trusted' }));

expect(screen.getByText('Trusted Agent')).toBeInTheDocument();
expect(screen.queryByText('New Agent')).not.toBeInTheDocument();
expect(screen.queryByText('Elite Agent')).not.toBeInTheDocument();
});

it('applies the selected tier filter alongside sorting', async () => {
(fetchAgents as jest.Mock).mockResolvedValue({
agents: [
makeAgent({ address: 'A', name: 'Building One', score: 350, total_payments: '2' }),
makeAgent({ address: 'B', name: 'Building Two', score: 400, total_payments: '8' }),
makeAgent({ address: 'C', name: 'Trusted Agent', score: 950, total_payments: '4' }),
],
total: 3,
page: 0,
pageSize: PAGE_SIZE,
});
(fetchAgentStats as jest.Mock).mockResolvedValue(mockStats);

renderPage();

await waitFor(() => {
expect(screen.getByText('Building One')).toBeInTheDocument();
});

fireEvent.change(screen.getByLabelText('Sort agents'), { target: { value: 'payments' } });
fireEvent.click(screen.getByRole('button', { name: 'Building' }));

const visibleNames = screen.getAllByRole('link', { name: /Building/i }).map((element) => element.textContent);
expect(visibleNames).toContain('Building Two');
expect(visibleNames[0]).toBe('Building Two');
});

it('shows an empty state when a selected tier has no matches', async () => {
(fetchAgents as jest.Mock).mockResolvedValue({
agents: [makeAgent({ address: 'A', name: 'New Agent', score: 100, total_payments: '1' })],
total: 1,
page: 0,
pageSize: PAGE_SIZE,
});
(fetchAgentStats as jest.Mock).mockResolvedValue(mockStats);

renderPage();

await waitFor(() => {
expect(screen.getByText('New Agent')).toBeInTheDocument();
});

fireEvent.click(screen.getByRole('button', { name: 'Elite' }));

expect(screen.getByText('No agents match the selected tier.')).toBeInTheDocument();
});

it('resets the filter when the All chip is selected', async () => {
(fetchAgents as jest.Mock).mockResolvedValue({
agents: [
makeAgent({ address: 'A', name: 'New Agent', score: 100, total_payments: '1' }),
makeAgent({ address: 'B', name: 'Trusted Agent', score: 950, total_payments: '4' }),
],
total: 2,
page: 0,
pageSize: PAGE_SIZE,
});
(fetchAgentStats as jest.Mock).mockResolvedValue(mockStats);

renderPage();

await waitFor(() => {
expect(screen.getByText('Trusted Agent')).toBeInTheDocument();
});

fireEvent.click(screen.getByRole('button', { name: 'Trusted' }));
fireEvent.click(screen.getByRole('button', { name: 'All' }));

expect(screen.getByText('New Agent')).toBeInTheDocument();
expect(screen.getByText('Trusted Agent')).toBeInTheDocument();
});
});
73 changes: 61 additions & 12 deletions frontend/app/agents/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import { useEffect, useState } from 'react';
import useSWR from 'swr';
import Link from 'next/link';
import type { AgentsResponse, AgentStats, AgentSortOption } from '@/lib/types';
import type { AgentsResponse, AgentStats, AgentSortOption, ScoreTier } from '@/lib/types';
import { scoreTier, TIER_LABELS } from '@/lib/types';
import { fetchAgents, fetchAgentStats } from '@/lib/contract';
import { sortAgents } from '@/lib/sort';
import AgentCard from '@/components/AgentCard';
Expand All @@ -16,10 +17,19 @@ const SORTS: { label: string; value: AgentSortOption }[] = [
{ label: 'Newest', value: 'newest' },
];

const TIER_FILTERS: { label: string; value: ScoreTier | 'all' }[] = [
{ label: 'All', value: 'all' },
...(['new', 'building', 'established', 'trusted', 'elite'] as const).map((value) => ({
label: TIER_LABELS[value],
value,
})),
];

import { PAGE_SIZE, PAGE_SIZE_OPTIONS } from '@/lib/pagination';

export default function AgentsPage() {
const [sort, setSort] = useState<AgentSortOption>('score');
const [activeTier, setActiveTier] = useState<ScoreTier | 'all'>('all');
const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState<number>(PAGE_SIZE);

Expand Down Expand Up @@ -57,18 +67,21 @@ export default function AgentsPage() {
: 'Failed to load'
: null;

// Sort agents locally based on the selected sort option
const sortedAgents = sortAgents(agents, sort);
const filteredAgents =
activeTier === 'all' ? agents : agents.filter((agent) => scoreTier(agent.score) === activeTier);

// Clamp the page if the dataset shrank (e.g. agents removed between polls).
const sortedAgents = sortAgents(filteredAgents, sort);
const visibleCount = sortedAgents.length;

// Clamp the page if the filtered dataset shrank (e.g. agents removed or tier changed).
useEffect(() => {
if (!data) return;
const maxPage = data.total > 0 ? Math.max(0, Math.ceil(data.total / pageSize) - 1) : 0;
const maxPage = visibleCount > 0 ? Math.max(0, Math.ceil(visibleCount / pageSize) - 1) : 0;
if (page > maxPage) setPage(maxPage);
}, [data, page, pageSize]);
}, [page, pageSize, visibleCount]);

const totalPages = Math.max(1, Math.ceil(total / pageSize));
const totalPages = Math.max(1, Math.ceil(visibleCount / pageSize));
const pageStart = page * pageSize;
const pagedAgents = sortedAgents.slice(pageStart, pageStart + pageSize);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

function handleSortChange(next: AgentSortOption) {
setSort(next);
Expand All @@ -80,6 +93,11 @@ export default function AgentsPage() {
setPage(0);
}

function handleTierChange(next: ScoreTier | 'all') {
setActiveTier(next);
setPage(0);
}

return (
<div className="max-w-6xl mx-auto px-6 py-12">
{/* Header */}
Expand Down Expand Up @@ -137,7 +155,7 @@ export default function AgentsPage() {
)}

{/* Score tier legend */}
<div className="card p-4 mb-8 flex flex-wrap gap-3 items-center">
<div className="card p-4 mb-6 flex flex-wrap gap-3 items-center">
<span className="text-xs text-secondary font-medium uppercase tracking-widest mr-2">Score tiers</span>
{([100, 450, 700, 950, 1000] as const).map((score) => (
<ScoreBadge key={score} score={score} showScore={false} />
Expand All @@ -147,6 +165,24 @@ export default function AgentsPage() {
</span>
</div>

<div className="flex flex-wrap gap-2 mb-8" role="group" aria-label="Filter agents by score tier">
{TIER_FILTERS.map((tier) => (
<button
key={tier.value}
type="button"
onClick={() => handleTierChange(tier.value)}
aria-pressed={activeTier === tier.value}
className={`px-4 py-1.5 rounded-full text-sm border transition-colors focus:outline-none focus:ring-1 focus:ring-primary ${
activeTier === tier.value
? 'bg-primary text-white border-primary'
: 'border-border text-secondary hover:border-primary hover:text-primary'
}`}
>
{tier.label}
</button>
))}
</div>

{/* Content */}
{loading && (
<div className="grid md:grid-cols-2 xl:grid-cols-3 gap-5">
Expand Down Expand Up @@ -184,18 +220,31 @@ export default function AgentsPage() {
</div>
)}

{!loading && !error && total > 0 && (
{!loading && !error && total > 0 && visibleCount === 0 && (
<div className="card p-12 text-center">
<p className="text-secondary text-sm mb-4">No agents match the selected tier.</p>
<button
type="button"
onClick={() => handleTierChange('all')}
className="btn-primary px-5 py-2.5 text-sm"
>
Show all agents
</button>
</div>
)}

{!loading && !error && total > 0 && visibleCount > 0 && (
<div className={refreshing ? 'opacity-60 transition-opacity duration-150' : ''}>
<div className="grid md:grid-cols-2 xl:grid-cols-3 gap-5">
{sortedAgents.map((agent) => (
{pagedAgents.map((agent) => (
<AgentCard key={agent.address} agent={agent} />
))}
</div>

{totalPages > 1 && (
<div className="flex items-center justify-between mt-8 gap-4">
<span className="text-sm text-secondary">
Showing {pageStart + 1}–{Math.min(pageStart + pageSize, total)} of {total}
Showing {pageStart + 1}–{Math.min(pageStart + pageSize, visibleCount)} of {visibleCount}
</span>
<div className="flex items-center gap-2">
<button
Expand Down
Loading