fix(tinyplace): paginate the Tiny Place ledger listing (#4952)

This commit is contained in:
Mega Mind
2026-07-17 11:47:46 +03:00
committed by GitHub
parent ce7bcca23d
commit a81252f65d
16 changed files with 298 additions and 16 deletions
@@ -17,6 +17,7 @@ import LedgerSection, {
abbreviateAddress,
formatAmount,
formatLedgerAmount,
LEDGER_PAGE_SIZE,
StatusBadge,
} from './LedgerSection';
@@ -43,6 +44,15 @@ const sampleTransaction: GqlLedgerTransaction = {
metadata: { identity: '@test-agent' },
};
/** Build `n` distinct SALE rows with sequential ids, starting at `start`. */
function buildPage(n: number, start = 0): Array<GqlLedgerTransaction> {
return Array.from({ length: n }, (_, i) => ({
...sampleTransaction,
txId: `tx-${String(start + i).padStart(4, '0')}`,
type: 'SALE',
}));
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(apiClient.graphql.ledgerTransactions).mockResolvedValue({ transactions: [], count: 0 });
@@ -103,6 +113,117 @@ describe('Ledger list', () => {
});
});
// ── Pagination ────────────────────────────────────────────────────────────────
describe('Ledger pagination', () => {
test('requests the first page with limit + offset 0', async () => {
vi.mocked(apiClient.graphql.ledgerTransactions).mockResolvedValue({
transactions: [sampleTransaction],
count: 1,
});
render(<LedgerSection />);
await waitFor(() => {
expect(screen.getByText('REGISTRATION')).toBeInTheDocument();
});
expect(apiClient.graphql.ledgerTransactions).toHaveBeenNthCalledWith(1, {
limit: LEDGER_PAGE_SIZE,
offset: 0,
});
});
test('hides Load more when the first page is shorter than a full page', async () => {
vi.mocked(apiClient.graphql.ledgerTransactions).mockResolvedValue({
transactions: buildPage(3),
count: 3,
});
render(<LedgerSection />);
await waitFor(() => {
expect(screen.getAllByText('SALE')).toHaveLength(3);
});
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
});
test('shows Load more when the first page fills the page size', async () => {
vi.mocked(apiClient.graphql.ledgerTransactions).mockResolvedValue({
transactions: buildPage(LEDGER_PAGE_SIZE),
count: LEDGER_PAGE_SIZE,
});
render(<LedgerSection />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument();
});
});
test('clicking Load more fetches the next offset, appends rows, then stops', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.ledgerTransactions)
.mockResolvedValueOnce({ transactions: buildPage(LEDGER_PAGE_SIZE, 0), count: 53 })
.mockResolvedValueOnce({ transactions: buildPage(3, LEDGER_PAGE_SIZE), count: 53 });
render(<LedgerSection />);
await waitFor(() => {
expect(screen.getAllByText('SALE')).toHaveLength(LEDGER_PAGE_SIZE);
});
await user.click(screen.getByRole('button', { name: /load more/i }));
// Second page appended (50 + 3 = 53 rows) and the control disappears because
// the short page signals the ledger is exhausted.
await waitFor(() => {
expect(screen.getAllByText('SALE')).toHaveLength(LEDGER_PAGE_SIZE + 3);
});
expect(apiClient.graphql.ledgerTransactions).toHaveBeenNthCalledWith(2, {
limit: LEDGER_PAGE_SIZE,
offset: LEDGER_PAGE_SIZE,
});
expect(screen.queryByRole('button', { name: /load more/i })).not.toBeInTheDocument();
});
test('deduplicates overlapping rows across pages', async () => {
const user = userEvent.setup();
// Second page repeats the last id of the first page (tx-0049) plus one new row.
vi.mocked(apiClient.graphql.ledgerTransactions)
.mockResolvedValueOnce({ transactions: buildPage(LEDGER_PAGE_SIZE, 0), count: 60 })
.mockResolvedValueOnce({
transactions: buildPage(LEDGER_PAGE_SIZE, LEDGER_PAGE_SIZE - 1),
count: 60,
});
render(<LedgerSection />);
await waitFor(() => {
expect(screen.getAllByText('SALE')).toHaveLength(LEDGER_PAGE_SIZE);
});
await user.click(screen.getByRole('button', { name: /load more/i }));
// 50 initial + 50 returned 1 overlapping (tx-0049) = 99 unique rows.
await waitFor(() => {
expect(screen.getAllByText('SALE')).toHaveLength(2 * LEDGER_PAGE_SIZE - 1);
});
});
test('keeps rows and surfaces an error when Load more fails', async () => {
const user = userEvent.setup();
vi.mocked(apiClient.graphql.ledgerTransactions)
.mockResolvedValueOnce({ transactions: buildPage(LEDGER_PAGE_SIZE, 0), count: 99 })
.mockRejectedValueOnce(new Error('network failure'));
render(<LedgerSection />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument();
});
await user.click(screen.getByRole('button', { name: /load more/i }));
// Existing rows stay; an error message appears; the control remains for retry.
await waitFor(() => {
expect(screen.getByText(/could not load more transactions/i)).toBeInTheDocument();
});
expect(screen.getAllByText('SALE')).toHaveLength(LEDGER_PAGE_SIZE);
expect(screen.getByRole('button', { name: /load more/i })).toBeInTheDocument();
});
});
// ── StatusBadge ───────────────────────────────────────────────────────────────
describe('StatusBadge colors', () => {
+133 -16
View File
@@ -8,23 +8,50 @@
*
* Pattern mirrors FeedSection: useState + useEffect fetch, PanelScaffold
* wrapper, StatusBlock for loading/error/empty states.
*
* Pagination: the backend/SDK `ledgerTransactions` call accepts `limit`/`offset`
* (LedgerListParams) and returns a `count`, so the list is fetched a page at a
* time and extended via an offset-based "Load more" control. A page shorter than
* {@link LEDGER_PAGE_SIZE} means the ledger is exhausted (`hasMore=false`).
*/
import { useEffect, useState } from 'react';
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';
import PanelScaffold from '../../components/layout/PanelScaffold';
import Button from '../../components/ui/Button';
import { type GqlLedgerTransaction } from '../../lib/agentworld/invokeApiClient';
import { useT } from '../../lib/i18n/I18nContext';
import { apiClient } from '../AgentWorldShell';
import { decimalsForAsset, resolveAssetSymbol } from '../assets';
import { formatUnits, friendlyNetwork } from '../components/X402ConfirmDialog';
import { explorerTxUrl } from '../hooks/useX402Buy';
import { relativeTime } from './relativeTime';
const log = debug('agentworld:ledger');
/** Ledger rows fetched per page (also the initial page size). */
export const LEDGER_PAGE_SIZE = 50;
// ── State types ───────────────────────────────────────────────────────────────
type LedgerState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ok'; transactions: GqlLedgerTransaction[] };
| {
status: 'ok';
transactions: GqlLedgerTransaction[];
// Server-side cursor, in request units: how many rows to skip on the next
// page. Advances by LEDGER_PAGE_SIZE per fetch, decoupled from the client
// row count so dedupe never desyncs the offset.
nextOffset: number;
// A full page came back, so more rows may exist.
hasMore: boolean;
// A "Load more" fetch is in flight.
loadingMore: boolean;
// Non-null when the most recent "Load more" fetch failed (existing rows
// stay visible; the user can retry).
moreError: string | null;
};
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -298,24 +325,46 @@ function TransactionRow({
// ── LedgerSection (main export) ───────────────────────────────────────────────
export default function LedgerSection() {
const { t } = useT();
const [ledgerState, setLedgerState] = useState<LedgerState>({ status: 'loading' });
const [expandedTxId, setExpandedTxId] = useState<string | null>(null);
// ── Fetch ledger transactions ──────────────────────────────────────────────
// Guards async setState after unmount (the initial useEffect uses its own
// `cancelled` flag; "Load more" fetches outlive no single effect, so they read
// this ref instead).
const mountedRef = useRef(true);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
// ── Fetch first page of ledger transactions ────────────────────────────────
useEffect(() => {
let cancelled = false;
setLedgerState({ status: 'loading' });
log('loading first ledger page', { limit: LEDGER_PAGE_SIZE });
// TODO(phase-2-follow-up): implement pagination with offset or cursor.
void apiClient.graphql
.ledgerTransactions({ limit: 50 })
.ledgerTransactions({ limit: LEDGER_PAGE_SIZE, offset: 0 })
.then(result => {
if (cancelled) return;
const transactions = Array.isArray(result?.transactions) ? result.transactions : [];
setLedgerState({ status: 'ok', transactions });
const hasMore = transactions.length >= LEDGER_PAGE_SIZE;
log('loaded first ledger page', { received: transactions.length, hasMore });
setLedgerState({
status: 'ok',
transactions,
nextOffset: LEDGER_PAGE_SIZE,
hasMore,
loadingMore: false,
moreError: null,
});
})
.catch((err: unknown) => {
if (cancelled) return;
log('first ledger page failed', { error: String(err) });
setLedgerState({ status: 'error', message: String(err) });
});
@@ -324,6 +373,53 @@ export default function LedgerSection() {
};
}, []);
// ── Fetch the next page and append it ──────────────────────────────────────
// `offset` is passed in from the rendered 'ok' state so the cursor stays a
// pure function of pages requested. Reentry is prevented by disabling the
// button while `loadingMore` is set.
const loadMore = useCallback((offset: number) => {
log('loading more ledger rows', { offset, limit: LEDGER_PAGE_SIZE });
setLedgerState(prev =>
prev.status === 'ok' ? { ...prev, loadingMore: true, moreError: null } : prev
);
void apiClient.graphql
.ledgerTransactions({ limit: LEDGER_PAGE_SIZE, offset })
.then(result => {
if (!mountedRef.current) return;
const page = Array.isArray(result?.transactions) ? result.transactions : [];
const hasMore = page.length >= LEDGER_PAGE_SIZE;
setLedgerState(prev => {
if (prev.status !== 'ok') return prev;
// Dedupe by txId: if rows shifted between page fetches the overlap must
// not produce duplicate React keys or double-counted entries.
const seen = new Set(prev.transactions.map(tx => tx.txId));
const fresh = page.filter(tx => !seen.has(tx.txId));
log('appended ledger rows', {
received: page.length,
fresh: fresh.length,
total: prev.transactions.length + fresh.length,
hasMore,
});
return {
status: 'ok',
transactions: [...prev.transactions, ...fresh],
nextOffset: offset + LEDGER_PAGE_SIZE,
hasMore,
loadingMore: false,
moreError: null,
};
});
})
.catch((err: unknown) => {
if (!mountedRef.current) return;
log('load more failed', { error: String(err) });
setLedgerState(prev =>
prev.status === 'ok' ? { ...prev, loadingMore: false, moreError: String(err) } : prev
);
});
}, []);
// ── Render ─────────────────────────────────────────────────────────────────
let body: React.ReactNode;
@@ -351,17 +447,38 @@ export default function LedgerSection() {
/>
);
} else {
const { transactions, hasMore, loadingMore, moreError, nextOffset } = ledgerState;
body = (
<div className="rounded-lg border border-line bg-surface">
{ledgerState.transactions.map(tx => (
<TransactionRow
key={tx.txId}
tx={tx}
expanded={expandedTxId === tx.txId}
onToggle={() => setExpandedTxId(prev => (prev === tx.txId ? null : tx.txId))}
/>
))}
</div>
<>
<div className="rounded-lg border border-line bg-surface">
{transactions.map(tx => (
<TransactionRow
key={tx.txId}
tx={tx}
expanded={expandedTxId === tx.txId}
onToggle={() => setExpandedTxId(prev => (prev === tx.txId ? null : tx.txId))}
/>
))}
</div>
{moreError && (
<p className="mt-3 text-center text-xs text-red-600 dark:text-red-400">
{t('agentWorld.ledger.loadMoreError')}
</p>
)}
{hasMore && (
<div className="mt-3 flex justify-center">
<Button
variant="secondary"
size="sm"
disabled={loadingMore}
onClick={() => loadMore(nextOffset)}>
{loadingMore ? t('agentWorld.ledger.loadingMore') : t('agentWorld.ledger.loadMore')}
</Button>
</div>
)}
</>
);
}
+3
View File
@@ -440,6 +440,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'ساحة مفتوحة كبيرة تحيط بها المباني.',
'agentWorld.feed': 'التغذية',
'agentWorld.ledger': 'السجل',
'agentWorld.ledger.loadMore': 'تحميل المزيد',
'agentWorld.ledger.loadingMore': 'جارٍ تحميل المزيد…',
'agentWorld.ledger.loadMoreError': 'تعذّر تحميل المزيد من المعاملات. حاول مرة أخرى.',
'agentWorld.jobs': 'الوظائف',
'agentWorld.bounties': 'مكافآت',
'agentWorld.explore': 'استكشاف',
+3
View File
@@ -456,6 +456,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'ভবনঘেরা বড় খোলা প্লাজা।',
'agentWorld.feed': 'ফিড',
'agentWorld.ledger': 'লেজার',
'agentWorld.ledger.loadMore': 'আরও লোড করুন',
'agentWorld.ledger.loadingMore': 'আরও লোড হচ্ছে…',
'agentWorld.ledger.loadMoreError': 'আরও লেনদেন লোড করা যায়নি। আবার চেষ্টা করুন।',
'agentWorld.jobs': 'চাকরি',
'agentWorld.bounties': 'পুরস্কার',
'agentWorld.explore': 'অন্বেষণ করুন',
+4
View File
@@ -480,6 +480,10 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'Ein großer offener Platz mit Gebäuden ringsum.',
'agentWorld.feed': 'Feed',
'agentWorld.ledger': 'Kontobuch',
'agentWorld.ledger.loadMore': 'Mehr laden',
'agentWorld.ledger.loadingMore': 'Wird geladen…',
'agentWorld.ledger.loadMoreError':
'Weitere Transaktionen konnten nicht geladen werden. Bitte erneut versuchen.',
'agentWorld.jobs': 'Aufträge',
'agentWorld.bounties': 'Prämien',
'agentWorld.explore': 'Entdecken',
+3
View File
@@ -185,6 +185,9 @@ const en: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'A large open plaza ringed with buildings.',
'agentWorld.feed': 'Feed',
'agentWorld.ledger': 'Ledger',
'agentWorld.ledger.loadMore': 'Load more',
'agentWorld.ledger.loadingMore': 'Loading more…',
'agentWorld.ledger.loadMoreError': 'Could not load more transactions. Try again.',
'agentWorld.jobs': 'Jobs',
'agentWorld.bounties': 'Bounties',
'agentWorld.explore': 'Explore',
+3
View File
@@ -466,6 +466,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'Una gran plaza abierta rodeada de edificios.',
'agentWorld.feed': 'Noticias',
'agentWorld.ledger': 'Libro mayor',
'agentWorld.ledger.loadMore': 'Cargar más',
'agentWorld.ledger.loadingMore': 'Cargando más…',
'agentWorld.ledger.loadMoreError': 'No se pudieron cargar más transacciones. Inténtalo de nuevo.',
'agentWorld.jobs': 'Trabajos',
'agentWorld.bounties': 'Recompensas',
'agentWorld.explore': 'Explorar',
+3
View File
@@ -476,6 +476,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'Une grande place ouverte entourée de bâtiments.',
'agentWorld.feed': 'Fil',
'agentWorld.ledger': 'Grand livre',
'agentWorld.ledger.loadMore': 'Charger plus',
'agentWorld.ledger.loadingMore': 'Chargement…',
'agentWorld.ledger.loadMoreError': 'Impossible de charger plus de transactions. Réessayez.',
'agentWorld.jobs': 'Missions',
'agentWorld.bounties': 'Primes',
'agentWorld.explore': 'Explorer',
+3
View File
@@ -456,6 +456,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'इमारतों से घिरा बड़ा खुला प्लाजा।',
'agentWorld.feed': 'फ़ीड',
'agentWorld.ledger': 'खाता बही',
'agentWorld.ledger.loadMore': 'और लोड करें',
'agentWorld.ledger.loadingMore': 'और लोड हो रहा है…',
'agentWorld.ledger.loadMoreError': 'अधिक लेन-देन लोड नहीं हो सके। फिर से प्रयास करें।',
'agentWorld.jobs': 'कार्य',
'agentWorld.bounties': 'इनाम',
'agentWorld.explore': 'एक्सप्लोर करें',
+3
View File
@@ -462,6 +462,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'Plaza terbuka besar yang dikelilingi gedung.',
'agentWorld.feed': 'Feed',
'agentWorld.ledger': 'Buku Besar',
'agentWorld.ledger.loadMore': 'Muat lebih banyak',
'agentWorld.ledger.loadingMore': 'Memuat lagi…',
'agentWorld.ledger.loadMoreError': 'Tidak dapat memuat transaksi lainnya. Coba lagi.',
'agentWorld.jobs': 'Pekerjaan',
'agentWorld.bounties': 'Hadiah',
'agentWorld.explore': 'Jelajahi',
+3
View File
@@ -469,6 +469,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'Una grande piazza aperta circondata da edifici.',
'agentWorld.feed': 'Feed',
'agentWorld.ledger': 'Registro',
'agentWorld.ledger.loadMore': 'Carica altro',
'agentWorld.ledger.loadingMore': 'Caricamento…',
'agentWorld.ledger.loadMoreError': 'Impossibile caricare altre transazioni. Riprova.',
'agentWorld.jobs': 'Lavori',
'agentWorld.bounties': 'Ricompense',
'agentWorld.explore': 'Esplora',
+3
View File
@@ -449,6 +449,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': '건물로 둘러싸인 넓은 열린 광장.',
'agentWorld.feed': '피드',
'agentWorld.ledger': '원장',
'agentWorld.ledger.loadMore': '더 보기',
'agentWorld.ledger.loadingMore': '더 불러오는 중…',
'agentWorld.ledger.loadMoreError': '거래를 더 불러오지 못했습니다. 다시 시도하세요.',
'agentWorld.jobs': '채용',
'agentWorld.bounties': '현상금',
'agentWorld.explore': '탐색',
+4
View File
@@ -467,6 +467,10 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'Duży otwarty plac otoczony budynkami.',
'agentWorld.feed': 'Kanał',
'agentWorld.ledger': 'Księga',
'agentWorld.ledger.loadMore': 'Załaduj więcej',
'agentWorld.ledger.loadingMore': 'Ładowanie…',
'agentWorld.ledger.loadMoreError':
'Nie udało się załadować kolejnych transakcji. Spróbuj ponownie.',
'agentWorld.jobs': 'Zlecenia',
'agentWorld.bounties': 'Nagrody',
'agentWorld.explore': 'Eksploruj',
+3
View File
@@ -461,6 +461,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'Uma grande praça aberta cercada por edifícios.',
'agentWorld.feed': 'Feed',
'agentWorld.ledger': 'Livro-razão',
'agentWorld.ledger.loadMore': 'Carregar mais',
'agentWorld.ledger.loadingMore': 'Carregando mais…',
'agentWorld.ledger.loadMoreError': 'Não foi possível carregar mais transações. Tente novamente.',
'agentWorld.jobs': 'Trabalhos',
'agentWorld.bounties': 'Recompensas',
'agentWorld.explore': 'Explorar',
+3
View File
@@ -461,6 +461,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': 'Большая открытая площадь, окруженная зданиями.',
'agentWorld.feed': 'Лента',
'agentWorld.ledger': 'Реестр',
'agentWorld.ledger.loadMore': 'Загрузить ещё',
'agentWorld.ledger.loadingMore': 'Загрузка…',
'agentWorld.ledger.loadMoreError': 'Не удалось загрузить больше транзакций. Попробуйте ещё раз.',
'agentWorld.jobs': 'Вакансии',
'agentWorld.bounties': 'Награды',
'agentWorld.explore': 'Исследовать',
+3
View File
@@ -425,6 +425,9 @@ const messages: TranslationMap = {
'agentWorld.world.rooms.outside.description': '一座被建筑环绕的大型开放广场。',
'agentWorld.feed': '动态',
'agentWorld.ledger': '账本',
'agentWorld.ledger.loadMore': '加载更多',
'agentWorld.ledger.loadingMore': '正在加载…',
'agentWorld.ledger.loadMoreError': '无法加载更多交易,请重试。',
'agentWorld.jobs': '工作',
'agentWorld.bounties': '赏金',
'agentWorld.explore': '探索',