diff --git a/api-specs/openrpc-user-api.json b/api-specs/openrpc-user-api.json index 06da0222f..e419825e7 100644 --- a/api-specs/openrpc-user-api.json +++ b/api-specs/openrpc-user-api.json @@ -876,7 +876,28 @@ }, { "name": "listTransactions", - "params": [], + "params": [ + { + "name": "params", + "schema": { + "title": "ListTransactionsParams", + "type": "object", + "additionalProperties": false, + "properties": { + "limit": { + "title": "limit", + "type": "integer", + "description": "Limit of transactions to return." + }, + "cursor": { + "title": "cursor", + "type": ["string"], + "description": "Cursor for next page of results." + } + } + } + } + ], "result": { "name": "result", "schema": { @@ -890,9 +911,19 @@ "items": { "$ref": "#/components/schemas/Transaction" } + }, + "nextCursor": { + "title": "nextCursor", + "type": "string", + "description": "Cursor for next page of results." + }, + "count": { + "title": "count", + "type": "number", + "description": "Number of total transactions for the user." } }, - "required": ["transactions"] + "required": ["transactions", "count"] } } }, diff --git a/core/wallet-store-inmemory/src/store-internal.test.ts b/core/wallet-store-inmemory/src/store-internal.test.ts index bb77189f7..d954a370c 100644 --- a/core/wallet-store-inmemory/src/store-internal.test.ts +++ b/core/wallet-store-inmemory/src/store-internal.test.ts @@ -78,6 +78,19 @@ const implementations: Array<[string, StoreCtor]> = [ ['StoreInternal', StoreInternal], ] +function addTx(id: string, createdAt: Date | undefined) { + const initial: Transaction = { + id: id, + commandId: 'cmd-immutable', + status: 'pending', + preparedTransaction: 'prepared-1', + preparedTransactionHash: 'hash-1', + payload: { amount: 100 }, + origin: 'https://safe.example', + createdAt: createdAt, + } + return initial +} implementations.forEach(([name, StoreImpl]) => { describe(name, () => { let store: Store @@ -663,7 +676,7 @@ implementations.forEach(([name, StoreImpl]) => { new Date('2026-01-01T00:01:00.000Z') ) - const duplicates = await store.listTransactions() + const duplicates = (await store.listTransactions()).transactions expect( duplicates.filter((tx) => tx.commandId === initial.commandId) ).toHaveLength(2) @@ -767,7 +780,9 @@ implementations.forEach(([name, StoreImpl]) => { ).rejects.toThrow('Transaction not found') await store.removeTransaction('tx-0') - expect(await store.listTransactions()).toHaveLength(1) + expect((await store.listTransactions()).transactions).toHaveLength( + 1 + ) }) test('should manage message signing requests', async () => { @@ -870,5 +885,84 @@ implementations.forEach(([name, StoreImpl]) => { 'ApiKey userId mismatch: expected other-user-id, got test-user-id' ) }) + + test('paginate list transactions', async () => { + for (let i = 0; i < 10; i++) { + const date = + i % 2 > 0 + ? new Date(`2026-01-02T00:00:00.000Z`) + : new Date(`2026-01-01T00:00:00.000Z`) + + const tx = addTx(`tx${i}`, date) + + await store.setTransaction(tx) + } + + const listAllTxs = await store.listTransactions() + + const collected: Transaction[] = [] + + let page = await store.listTransactions({ limit: 5 }) + collected.push(...page.transactions) + let timesCalled = 1 + while (page.nextCursor !== null) { + timesCalled++ + page = await store.listTransactions({ + cursor: page.nextCursor, + limit: 5, + }) + collected.push(...page.transactions) + } + + expect(listAllTxs.transactions).toEqual(collected) + expect(timesCalled).toBe(2) + }) + + test('paginate list transactions if createdAt is null for some txs', async () => { + for (let i = 0; i < 10; i++) { + const date = + i % 2 > 0 ? new Date(`2026-01-02T00:00:00.000Z`) : undefined + + const tx = addTx(`tx${i}`, date) + + await store.setTransaction(tx) + } + + const listAllTxs = await store.listTransactions() + + const collected: Transaction[] = [] + + let page = await store.listTransactions({ limit: 5 }) + collected.push(...page.transactions) + let timesCalled = 1 + + while (page.nextCursor !== null) { + timesCalled++ + page = await store.listTransactions({ + cursor: page.nextCursor, + limit: 5, + }) + collected.push(...page.transactions) + } + + expect(listAllTxs.transactions).toEqual(collected) + expect(timesCalled).toBe(2) + }) + + test('return total number of transactions', async () => { + for (let i = 0; i < 10; i++) { + const date = + i % 2 > 0 + ? new Date(`2026-01-02T00:00:00.000Z`) + : new Date(`2026-01-01T00:00:00.000Z`) + + const tx = addTx(`tx${i}`, date) + + await store.setTransaction(tx) + } + + const count = await store.transactionsCount() + expect(count).toBe(10) + }) }) }) diff --git a/core/wallet-store-inmemory/src/store-internal.ts b/core/wallet-store-inmemory/src/store-internal.ts index db144520e..55cd74228 100644 --- a/core/wallet-store-inmemory/src/store-internal.ts +++ b/core/wallet-store-inmemory/src/store-internal.ts @@ -23,6 +23,7 @@ import { MessageRaw, MessageRawStatusUpdate, ApiKey, + ListTransactionsOptions, } from '@canton-network/core-wallet-store' import { CurrentNetworkWalletFilter } from '@canton-network/core-wallet-store' @@ -454,11 +455,69 @@ export class StoreInternal implements Store, AuthAware { })[0] } - async listTransactions(): Promise> { + async transactionsCount(): Promise { this.assertConnected() const storage = this.getStorage() + return storage.transactions.size + } + + async listTransactions(options?: ListTransactionsOptions) { + this.assertConnected() + const storage = this.getStorage() + const { cursor, limit } = options ?? {} + const lim = limit ? Math.min(limit, 100) : 100 + + const sortedTxs = Array.from(storage.transactions.values()).sort( + (a, b) => { + if (a.createdAt && b.createdAt) { + const diff = + new Date(b.createdAt).getTime() - + new Date(a.createdAt).getTime() + if (diff !== 0) return diff + } else if (a.createdAt && !b.createdAt) { + return -1 + } else if (!a.createdAt && b.createdAt) { + return 1 + } - return Array.from(storage.transactions.values()).sort(byCreatedAtDesc) + return b.id.localeCompare(a.id) + } + ) + + let startIndex = 0 + if (cursor) { + const [cursorDate, cursorId] = cursor.split('::') + const index = sortedTxs.findIndex((tx) => { + const txDateStr = tx.createdAt + ? new Date(tx.createdAt).toISOString() + : 'null' + return txDateStr === cursorDate && tx.id === cursorId + }) + if (index !== -1) { + startIndex = index + 1 + } + } + + const pagedTxs = sortedTxs.slice(startIndex, startIndex + lim + 1) + const hasNextPage = pagedTxs.length > lim + if (hasNextPage) { + pagedTxs.pop() + } + + if (pagedTxs.length === 0) { + return { transactions: [], nextCursor: null } + } + + const lastTx = pagedTxs[pagedTxs.length - 1] + const d = lastTx.createdAt + ? new Date(lastTx.createdAt).toISOString() + : 'null' + const nextCursor = hasNextPage ? `${d}::${lastTx.id}` : null + + return { + transactions: pagedTxs, + nextCursor: nextCursor, + } } async removeTransaction(transactionId: string): Promise { diff --git a/core/wallet-store-sql/src/store-sql.test.ts b/core/wallet-store-sql/src/store-sql.test.ts index 115a3d5e3..34c8bb9cc 100644 --- a/core/wallet-store-sql/src/store-sql.test.ts +++ b/core/wallet-store-sql/src/store-sql.test.ts @@ -77,6 +77,20 @@ const network: Network = { auth, } +function addTx(id: string, createdAt: Date | undefined) { + const initial: Transaction = { + id: id, + commandId: 'cmd-immutable', + status: 'pending', + preparedTransaction: 'prepared-1', + preparedTransactionHash: 'hash-1', + payload: { amount: 100 }, + origin: 'https://safe.example', + createdAt: createdAt, + } + return initial +} + implementations.forEach(([name, StoreImpl]) => { describe(name, () => { let db: Kysely @@ -619,12 +633,116 @@ implementations.forEach(([name, StoreImpl]) => { new Date('2026-01-01T00:01:00.000Z') ) - const duplicates = await store.listTransactions() + const duplicates = (await store.listTransactions()).transactions expect( duplicates.filter((tx) => tx.commandId === initial.commandId) ).toHaveLength(2) }) + test('paginate list transactions', async () => { + const store = new StoreImpl(db, pino(sink()), authContextMock) + await store.addIdp(idp) + await store.addNetwork(network) + await store.setSession({ + id: 'session-tx-immutable', + network: 'network1', + accessToken: 'token', + }) + + for (let i = 0; i < 10; i++) { + const date = + i % 2 > 0 + ? new Date(`2026-01-02T00:00:00.000Z`) + : new Date(`2026-01-01T00:00:00.000Z`) + + const tx = addTx(`tx${i}`, date) + + await store.setTransaction(tx) + } + + const listAllTxs = await store.listTransactions() + + const collected: Transaction[] = [] + + let page = await store.listTransactions({ limit: 5 }) + collected.push(...page.transactions) + let timesCalled = 1 + while (page.nextCursor !== null) { + timesCalled++ + page = await store.listTransactions({ + cursor: page.nextCursor, + limit: 5, + }) + collected.push(...page.transactions) + } + + expect(listAllTxs.transactions).toEqual(collected) + expect(timesCalled).toBe(2) + }) + + test('paginate list transactions if createdAt is null for some txs', async () => { + const store = new StoreImpl(db, pino(sink()), authContextMock) + await store.addIdp(idp) + await store.addNetwork(network) + await store.setSession({ + id: 'session-tx-immutable', + network: 'network1', + accessToken: 'token', + }) + + for (let i = 0; i < 10; i++) { + const date = + i % 2 > 0 ? new Date(`2026-01-02T00:00:00.000Z`) : undefined + + const tx = addTx(`tx${i}`, date) + + await store.setTransaction(tx) + } + + const listAllTxs = await store.listTransactions() + + const collected: Transaction[] = [] + + let page = await store.listTransactions({ limit: 5 }) + collected.push(...page.transactions) + let timesCalled = 1 + + while (page.nextCursor !== null) { + timesCalled++ + page = await store.listTransactions({ + cursor: page.nextCursor, + limit: 5, + }) + collected.push(...page.transactions) + } + + expect(listAllTxs.transactions).toEqual(collected) + expect(timesCalled).toBe(2) + }) + + test('count correct number of transactions', async () => { + const store = new StoreImpl(db, pino(sink()), authContextMock) + await store.addIdp(idp) + await store.addNetwork(network) + await store.setSession({ + id: 'session-tx-immutable', + network: 'network1', + accessToken: 'token', + }) + + for (let i = 0; i < 10; i++) { + const date = + i % 2 > 0 ? new Date(`2026-01-02T00:00:00.000Z`) : undefined + + const tx = addTx(`tx${i}`, date) + + await store.setTransaction(tx) + } + + const count = await store.transactionsCount() + expect(count).toBe(10) + }) + test('removeWallet should cascade-delete userPartyRights', async () => { const store = new StoreImpl(db, pino(sink()), authContextMock) await store.addIdp(idp) @@ -697,7 +815,9 @@ implementations.forEach(([name, StoreImpl]) => { 'network1', ]) expect(await store.getWallets()).toHaveLength(1) - expect(await store.listTransactions()).toHaveLength(1) + expect((await store.listTransactions()).transactions).toHaveLength( + 1 + ) await store.removeNetwork('network1') @@ -858,7 +978,9 @@ implementations.forEach(([name, StoreImpl]) => { await store.removeTransaction('tx-old') expect(await store.getTransaction('tx-old')).toBeUndefined() - expect(await store.listTransactions()).toHaveLength(1) + expect((await store.listTransactions()).transactions).toHaveLength( + 1 + ) }) test('should throw when updating a missing transaction or message', async () => { diff --git a/core/wallet-store-sql/src/store-sql.ts b/core/wallet-store-sql/src/store-sql.ts index f3f5da765..b6cec04d5 100644 --- a/core/wallet-store-sql/src/store-sql.ts +++ b/core/wallet-store-sql/src/store-sql.ts @@ -26,6 +26,7 @@ import { TransactionStatusUpdate, UserLevelRight, ApiKey, + ListTransactionsOptions, } from '@canton-network/core-wallet-store' import { CamelCasePlugin, Kysely, PostgresDialect, SqliteDialect } from 'kysely' import Database from 'better-sqlite3' @@ -49,6 +50,7 @@ import { toSession, } from './schema.js' import pg from 'pg' +import { sql } from 'kysely' export class StoreSql implements BaseStore, AuthAware { authContext: AuthContext | undefined @@ -713,17 +715,37 @@ export class StoreSql implements BaseStore, AuthAware { eb('networkId', '=', network.id), ]) ) - .orderBy('createdAt', 'desc') - .orderBy('id', 'desc') + .orderBy(sql`${sql.ref('createdAt')} desc nulls last`) .executeTakeFirst() return transaction ? toTransaction(transaction) : undefined } - async listTransactions(): Promise> { + async transactionsCount(): Promise { + const userId = this.assertConnected() + const network = await this.getCurrentNetwork() + + const result = await this.db + .selectFrom('transactions') + .where((eb) => + eb.and([ + eb('userId', '=', userId), + eb('networkId', '=', network.id), + ]) + ) + .select((eb) => eb.fn.count('id').as('count')) + .executeTakeFirstOrThrow() + + return Number(result.count) + } + + async listTransactions(options?: ListTransactionsOptions) { const userId = this.assertConnected() const network = await this.getCurrentNetwork() - const transactions = await this.db + const { cursor, limit } = options ?? {} + const lim = limit ? Math.min(limit, 100) : 100 + + let query = this.db .selectFrom('transactions') .selectAll() .where((eb) => @@ -732,9 +754,72 @@ export class StoreSql implements BaseStore, AuthAware { eb('networkId', '=', network.id), ]) ) - .orderBy('createdAt', 'desc') - .execute() - return transactions.map((table) => toTransaction(table)) + .orderBy(sql`${sql.ref('createdAt')} desc nulls last`) + .orderBy('id', 'desc') + .limit(lim + 1) + + if (cursor) { + const split = cursor.split('::') + const dateString = split[0] === 'null' ? null : split[0] + const cursorId = split[1] + + query = query.where((eb) => { + if (dateString === null) { + return eb.and([ + eb('createdAt', 'is', null), + eb('id', '<', cursorId), + ]) + } + + return eb.or([ + eb('createdAt', '<', dateString), + eb.and([ + eb('createdAt', '=', dateString), + eb('id', '<', split[1]), + ]), + eb('createdAt', 'is', null), + ]) + }) + } + + const rows = await query.execute() + const txs = rows.map((r) => toTransaction(r)) + + const hasNextPage = txs.length > lim + if (hasNextPage) { + txs.pop() + } + + if (txs.length === 0) { + return { + transactions: [], + nextCursor: null, + } + } + + const lastTx = txs[txs.length - 1] + if (lastTx === undefined) { + return { + transactions: txs, + nextCursor: null, + } + } + + //handle case for if timestamp is null + if (lastTx.createdAt) { + const d = new Date(lastTx.createdAt).toISOString() + const nextCursor = hasNextPage ? `${d}::${lastTx.id}` : null + return { + transactions: txs, + nextCursor: nextCursor, + } + } else { + const nextCursor = hasNextPage ? `null::${lastTx.id}` : null + return { + transactions: txs, + nextCursor: nextCursor, + } + } } async removeTransaction(transactionId: string): Promise { diff --git a/core/wallet-store/src/Store.ts b/core/wallet-store/src/Store.ts index 03cb9a05d..96d28b074 100644 --- a/core/wallet-store/src/Store.ts +++ b/core/wallet-store/src/Store.ts @@ -100,6 +100,11 @@ export interface TransactionStatusUpdate { externalTxId?: string } +export interface ListTransactionsOptions { + cursor?: string + limit?: number +} + export interface MessageRaw { id: string status: 'pending' | 'signed' | 'failed' @@ -183,9 +188,12 @@ export interface Store { getLatestTransactionByCommandId( commandId: string ): Promise - listTransactions(): Promise> + listTransactions( + options?: ListTransactionsOptions + ): Promise<{ transactions: Array; nextCursor: string | null }> listAllPendingTransactions(): Promise> removeTransaction(transactionId: string): Promise + transactionsCount(): Promise // Message signing request methods setMessageRaw(message: MessageRaw): Promise diff --git a/core/wallet-user-rpc-client/src/index.ts b/core/wallet-user-rpc-client/src/index.ts index f59eb3354..743007219 100644 --- a/core/wallet-user-rpc-client/src/index.ts +++ b/core/wallet-user-rpc-client/src/index.ts @@ -181,6 +181,24 @@ export type MessageId = string */ export type Signature = string export type SignedBy = string +/** + * + * Limit of transactions to return. + * + */ +export type Limit = number +/** + * + * Cursor for next page of results. + * + */ +export type CursorAsString = string +/** + * + * Cursor for next page of results. + * + */ +export type Cursor = CursorAsString /** * * Authentication method configured for this network @@ -433,6 +451,18 @@ export interface Transaction { externalTxId?: ExternalTxId } export type Transactions = Transaction[] +/** + * + * Cursor for next page of results. + * + */ +export type NextCursor = string +/** + * + * Number of total transactions for the user. + * + */ +export type Count = number /** * * The unique identifier of the current user. @@ -531,6 +561,10 @@ export interface AddSessionParams { export interface GetTransactionParams { transactionId: TransactionId } +export interface ListTransactionsParams { + limit?: Limit + cursor?: Cursor +} export interface DeleteTransactionParams { transactionId: TransactionId } @@ -635,6 +669,8 @@ export interface GetTransactionResult { } export interface ListTransactionsResult { transactions: Transactions + nextCursor?: NextCursor + count: Count } export interface GetUserResult { userId: UserIdentifier @@ -699,7 +735,9 @@ export type ListSessions = () => Promise export type GetTransaction = ( params: GetTransactionParams ) => Promise -export type ListTransactions = () => Promise +export type ListTransactions = ( + params: ListTransactionsParams +) => Promise export type DeleteTransaction = ( params: DeleteTransactionParams ) => Promise diff --git a/core/wallet-user-rpc-client/src/openrpc.json b/core/wallet-user-rpc-client/src/openrpc.json index 06da0222f..e419825e7 100644 --- a/core/wallet-user-rpc-client/src/openrpc.json +++ b/core/wallet-user-rpc-client/src/openrpc.json @@ -876,7 +876,28 @@ }, { "name": "listTransactions", - "params": [], + "params": [ + { + "name": "params", + "schema": { + "title": "ListTransactionsParams", + "type": "object", + "additionalProperties": false, + "properties": { + "limit": { + "title": "limit", + "type": "integer", + "description": "Limit of transactions to return." + }, + "cursor": { + "title": "cursor", + "type": ["string"], + "description": "Cursor for next page of results." + } + } + } + } + ], "result": { "name": "result", "schema": { @@ -890,9 +911,19 @@ "items": { "$ref": "#/components/schemas/Transaction" } + }, + "nextCursor": { + "title": "nextCursor", + "type": "string", + "description": "Cursor for next page of results." + }, + "count": { + "title": "count", + "type": "number", + "description": "Number of total transactions for the user." } }, - "required": ["transactions"] + "required": ["transactions", "count"] } } }, diff --git a/wallet-gateway/remote/src/user-api/controller.test.ts b/wallet-gateway/remote/src/user-api/controller.test.ts index 3d02f2b06..29b151cad 100644 --- a/wallet-gateway/remote/src/user-api/controller.test.ts +++ b/wallet-gateway/remote/src/user-api/controller.test.ts @@ -752,7 +752,7 @@ describe('userController', () => { auth ) - const result = await controller.listTransactions() + const result = await controller.listTransactions({}) expect(result.transactions).toHaveLength(1) expect(result.transactions[0]?.id).toBe('tx-1') }) diff --git a/wallet-gateway/remote/src/user-api/controller.ts b/wallet-gateway/remote/src/user-api/controller.ts index ccdb95b4e..da4c06101 100644 --- a/wallet-gateway/remote/src/user-api/controller.ts +++ b/wallet-gateway/remote/src/user-api/controller.ts @@ -44,6 +44,7 @@ import { RemoveApiKeyParams, ListSigningProviderVaultsResult, ListSigningProviderVaultsParams, + ListTransactionsParams, } from './rpc-gen/typings.js' import { Store, Network } from '@canton-network/core-wallet-store' import { Logger } from 'pino' @@ -1002,8 +1003,12 @@ export const userController = ( }), } }, - listTransactions: async function (): Promise { - const transactions = await store.listTransactions() + listTransactions: async function ( + params?: ListTransactionsParams + ): Promise { + const txCount = await store.transactionsCount() + const page = await store.listTransactions(params) + const transactions = page.transactions const txs = transactions.map((transaction) => ({ id: transaction.id, commandId: transaction.commandId, @@ -1026,7 +1031,16 @@ export const userController = ( externalTxId: transaction.externalTxId, }), })) - return { transactions: txs } + + if (page.nextCursor === null) { + return { transactions: txs, count: txCount } + } else { + return { + transactions: txs, + nextCursor: page.nextCursor, + count: txCount, + } + } }, deleteTransaction: async ( params: DeleteTransactionParams diff --git a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts index c808e0b3e..c5f5a3716 100644 --- a/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts +++ b/wallet-gateway/remote/src/user-api/rpc-gen/typings.ts @@ -180,6 +180,24 @@ export type MessageId = string */ export type Signature = string export type SignedBy = string +/** + * + * Limit of transactions to return. + * + */ +export type Limit = number +/** + * + * Cursor for next page of results. + * + */ +export type CursorAsString = string +/** + * + * Cursor for next page of results. + * + */ +export type Cursor = CursorAsString /** * * Authentication method configured for this network @@ -432,6 +450,18 @@ export interface Transaction { externalTxId?: ExternalTxId } export type Transactions = Transaction[] +/** + * + * Cursor for next page of results. + * + */ +export type NextCursor = string +/** + * + * Number of total transactions for the user. + * + */ +export type Count = number /** * * The unique identifier of the current user. @@ -530,6 +560,10 @@ export interface AddSessionParams { export interface GetTransactionParams { transactionId: TransactionId } +export interface ListTransactionsParams { + limit?: Limit + cursor?: Cursor +} export interface DeleteTransactionParams { transactionId: TransactionId } @@ -634,6 +668,8 @@ export interface GetTransactionResult { } export interface ListTransactionsResult { transactions: Transactions + nextCursor?: NextCursor + count: Count } export interface GetUserResult { userId: UserIdentifier @@ -698,7 +734,9 @@ export type ListSessions = () => Promise export type GetTransaction = ( params: GetTransactionParams ) => Promise -export type ListTransactions = () => Promise +export type ListTransactions = ( + params: ListTransactionsParams +) => Promise export type DeleteTransaction = ( params: DeleteTransactionParams ) => Promise diff --git a/wallet-gateway/remote/src/web/frontend/activities/index.test.ts b/wallet-gateway/remote/src/web/frontend/activities/index.test.ts index 8883fbf2c..4ab540431 100644 --- a/wallet-gateway/remote/src/web/frontend/activities/index.test.ts +++ b/wallet-gateway/remote/src/web/frontend/activities/index.test.ts @@ -116,17 +116,39 @@ describe('UserUiActivities', () => { expect(el.shadowRoot?.textContent).toContain('No activities yet') }) + it('shows pagination when there are more than one page of activities', async () => { + mockRequest.mockResolvedValue({ + transactions: makeTransactions(4), + count: 4, + }) + + el = await fixture(componentFixture) + + await waitUntil(() => el.transactions.length === 4) + const pagination = el.shadowRoot?.querySelector( + 'wg-pagination' + ) as HTMLElement & { count: number } + + expect(pagination).toBeNull() + }) it('shows pagination when there are more than one page of activities', async () => { mockRequest.mockResolvedValue({ - transactions: makeTransactions(5), + transactions: makeTransactions(4), + nextCursor: 'tx-4:randomisodatestring', + count: 5, }) el = await fixture(componentFixture) - await waitUntil(() => el.transactions.length === 5) + await waitUntil(() => el.transactions.length === 4) + const pagination = el.shadowRoot?.querySelector( + 'wg-pagination' + ) as HTMLElement & { count: number } + + expect(pagination).not.toBeNull() + expect(el.totalTransactionCount).toBe(5) - expect(el.shadowRoot?.querySelector('wg-pagination')).not.toBeNull() expect(getTransactionCards(el).map((c) => c.transactionId)).toEqual([ 'tx-1', 'tx-2', @@ -136,26 +158,44 @@ describe('UserUiActivities', () => { }) it('updates current page when pagination emits page-change', async () => { - mockRequest.mockResolvedValue({ - transactions: makeTransactions(5), + mockRequest.mockResolvedValueOnce({ + transactions: makeTransactions(4), + nextCursor: 'next-page-cursor', + count: 5, + }) + + mockRequest.mockResolvedValueOnce({ + transactions: [makeTransaction({ id: 'tx-5', commandId: 'cmd-5' })], + nextCursor: null, + count: 5, }) el = await fixture(componentFixture) - await waitUntil(() => el.transactions.length === 5) + await waitUntil(() => el.transactions.length === 4) const pagination = el.shadowRoot?.querySelector( 'wg-pagination' - ) as HTMLElement & { page: number } + ) as HTMLElement & { page: number; count: number } pagination!.dispatchEvent(new PageChangeEvent(2)) await waitUntil( - () => getTransactionCards(el)[0]?.transactionId === 'tx-5', + () => + el.transactions.length === 1 && + getTransactionCards(el)[0]?.transactionId === 'tx-5', 'page 2 rendered' ) expect(pagination!.page).toBe(2) expect(getTransactionCards(el)).toHaveLength(1) expect(getTransactionCards(el)[0]?.transactionId).toBe('tx-5') + expect(mockRequest).toHaveBeenCalledTimes(2) + expect(mockRequest).toHaveBeenNthCalledWith(2, { + method: 'listTransactions', + params: { + limit: 4, + cursor: 'next-page-cursor', + }, + }) }) it('navigates to approve page when a card emits transaction-review', async () => { diff --git a/wallet-gateway/remote/src/web/frontend/activities/index.ts b/wallet-gateway/remote/src/web/frontend/activities/index.ts index 9e2dfaa46..e5e0f7526 100644 --- a/wallet-gateway/remote/src/web/frontend/activities/index.ts +++ b/wallet-gateway/remote/src/web/frontend/activities/index.ts @@ -38,6 +38,11 @@ export class UserUiActivities extends BaseElement { @state() accessor currentPage = 1 + @state() + accessor totalTransactionCount = 0 + + private pageCursors: (string | undefined)[] = [undefined] + private readonly pageSize = 4 static styles = [ @@ -67,11 +72,6 @@ export class UserUiActivities extends BaseElement { `, ] - private get pagedTransactions() { - const start = (this.currentPage - 1) * this.pageSize - return this.transactions.slice(start, start + this.pageSize) - } - protected render() { return html`