From d4171a850b618ea148efe1d11ae67f0adfe55d42 Mon Sep 17 00:00:00 2001 From: Oluwatobi Omitogun <111443051+iBotayo@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:54:36 +0000 Subject: [PATCH 1/4] feat(agents): add score tier filter chips to agents page --- frontend/__tests__/AgentsPage.test.tsx | 144 + frontend/app/agents/page.tsx | 73 +- frontend/package-lock.json | 4328 +++++++++++++++++++++++- 3 files changed, 4455 insertions(+), 90 deletions(-) diff --git a/frontend/__tests__/AgentsPage.test.tsx b/frontend/__tests__/AgentsPage.test.tsx index b821b425..fc0a6c39 100644 --- a/frontend/__tests__/AgentsPage.test.tsx +++ b/frontend/__tests__/AgentsPage.test.tsx @@ -55,6 +55,19 @@ const mockStats: AgentStats = { totalVolumeStroops: '10000000', }; +function makeAgent(overrides: Partial = {}): 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(); @@ -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(); + }); +}); diff --git a/frontend/app/agents/page.tsx b/frontend/app/agents/page.tsx index 56bb0218..269ff15c 100644 --- a/frontend/app/agents/page.tsx +++ b/frontend/app/agents/page.tsx @@ -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'; @@ -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('score'); + const [activeTier, setActiveTier] = useState('all'); const [page, setPage] = useState(0); const [pageSize, setPageSize] = useState(PAGE_SIZE); @@ -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); function handleSortChange(next: AgentSortOption) { setSort(next); @@ -80,6 +93,11 @@ export default function AgentsPage() { setPage(0); } + function handleTierChange(next: ScoreTier | 'all') { + setActiveTier(next); + setPage(0); + } + return (
{/* Header */} @@ -137,7 +155,7 @@ export default function AgentsPage() { )} {/* Score tier legend */} -
+
Score tiers {([100, 450, 700, 950, 1000] as const).map((score) => ( @@ -147,6 +165,24 @@ export default function AgentsPage() {
+
+ {TIER_FILTERS.map((tier) => ( + + ))} +
+ {/* Content */} {loading && (
@@ -184,10 +220,23 @@ export default function AgentsPage() {
)} - {!loading && !error && total > 0 && ( + {!loading && !error && total > 0 && visibleCount === 0 && ( +
+

No agents match the selected tier.

+ +
+ )} + + {!loading && !error && total > 0 && visibleCount > 0 && (
- {sortedAgents.map((agent) => ( + {pagedAgents.map((agent) => ( ))}
@@ -195,7 +244,7 @@ export default function AgentsPage() { {totalPages > 1 && (
- Showing {pageStart + 1}–{Math.min(pageStart + pageSize, total)} of {total} + Showing {pageStart + 1}–{Math.min(pageStart + pageSize, visibleCount)} of {visibleCount}
)} - {!loading && !error && total === 0 && ( + {!loading && !error && !hasAnyAgents && (

No agents registered yet.

@@ -220,7 +216,7 @@ export default function AgentsPage() {
)} - {!loading && !error && total > 0 && visibleCount === 0 && ( + {!loading && !error && hasAnyAgents && total === 0 && (

No agents match the selected tier.