Skip to content

Commit

Permalink
Merge pull request #1954 from oasisprotocol/ml/pending-transactions
Browse files Browse the repository at this point in the history
Pending transactions
  • Loading branch information
lubej authored Jul 10, 2024
2 parents bd1a4aa + 09083aa commit 7ca812c
Show file tree
Hide file tree
Showing 47 changed files with 1,192 additions and 155 deletions.
7 changes: 7 additions & 0 deletions .changelog/1954.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Pending transactions

Introduces a section for pending transactions within the transaction history
interface. It is designed to display transactions currently in a pending
state that are made within the wallet. The section will also show up in case
there is a discrepancy between transaction history nonce and wallet nonce,
indicating that some transactions are currently in pending state.
13 changes: 13 additions & 0 deletions playwright/tests/transfer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,16 @@ test('Scrolling on amount input field should preserve value', async ({ page }) =
await page.mouse.wheel(0, 10)
await expect(input).toHaveValue('1111')
})

test('Should show pending transactions section', async ({ page }) => {
await page.getByTestId('nav-myaccount').click()

await page.getByPlaceholder('Enter an address').fill('oasis1qrf4y7aelwuusc270e8qx04ysr45w3q0zyavrpdk')
await page.getByPlaceholder('Enter an amount').fill('0.1')

await page.getByRole('button', { name: /Send/i }).click()
await page.getByRole('button', { name: /Confirm/i }).click()

await expect(page.getByRole('heading', { name: 'Pending transactions' })).toBeVisible()
await expect(page.getByText('Some transactions are currently in a pending state.')).toBeVisible()
})
18 changes: 18 additions & 0 deletions playwright/utils/mockApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,24 @@ export async function mockApi(context: BrowserContext | Page, balance: number) {
body: 'AAAAAAGggAAAAB5ncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6DQo=',
})
})
await context.route('**/oasis-core.Consensus/GetSignerNonce', route => {
route.fulfill({
contentType: 'application/grpc-web-text+proto',
body: 'AAAAAAIYKQ==gAAAAB5ncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6DQo=',
})
})
await context.route('**/oasis-core.Consensus/EstimateGas', route => {
route.fulfill({
contentType: 'application/grpc-web-text+proto',
body: 'AAAAAAMZBPE=gAAAAB5ncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6DQo=',
})
})
await context.route('**/oasis-core.Consensus/SubmitTx', route => {
route.fulfill({
contentType: 'application/grpc-web-text+proto',
body: 'AAAAAAA=gAAAAB5ncnBjLXN0YXR1czowDQpncnBjLW1lc3NhZ2U6DQo=',
})
})

// Inside Transak iframe
await context.route('https://sentry.io/**', route => route.fulfill({ body: '' }))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Account, AccountProps } from '../Account'

const props = {
address: 'oasis1qq3xrq0urs8qcffhvmhfhz4p0mu7ewc8rscnlwxe',
balance: { available: '200', debonding: '0', delegations: '800', total: '1000' },
balance: { available: '200', debonding: '0', delegations: '800', total: '1000', nonce: '0' },
onClick: () => {},
isActive: false,
displayBalance: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe('<AccountSelector />', () => {
wallets: {
oasis1qq3xrq0urs8qcffhvmhfhz4p0mu7ewc8rscnlwxe: {
address: 'oasis1qq3xrq0urs8qcffhvmhfhz4p0mu7ewc8rscnlwxe',
balance: { available: '100', debonding: '0', delegations: '0', total: '100' },
balance: { available: '100', debonding: '0', delegations: '0', total: '100', nonce: '0' },
publicKey: '00',
type: WalletType.UsbLedger,
},
Expand Down
2 changes: 2 additions & 0 deletions src/app/components/Transaction/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ describe('<Transaction />', () => {
runtimeName: undefined,
runtimeId: undefined,
round: undefined,
nonce: 0n.toString(),
},
network,
)
Expand All @@ -131,6 +132,7 @@ describe('<Transaction />', () => {
runtimeName: undefined,
runtimeId: undefined,
round: undefined,
nonce: 0n.toString(),
},
network,
)
Expand Down
36 changes: 27 additions & 9 deletions src/app/components/Transaction/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -462,9 +462,11 @@ export function Transaction(props: TransactionProps) {
</Box>

<Box pad="none">
<InfoBox icon={Clock} label={t('common.time', 'Time')}>
{intlDateTimeFormat(transaction.timestamp!)}
</InfoBox>
{transaction.timestamp && (
<InfoBox icon={Clock} label={t('common.time', 'Time')}>
{intlDateTimeFormat(transaction.timestamp)}
</InfoBox>
)}

{!transaction.runtimeId && transaction.level && (
<InfoBox icon={Cube} label={t('common.block', 'Block')}>
Expand All @@ -486,15 +488,31 @@ export function Transaction(props: TransactionProps) {
<AmountFormatter amount={transaction.amount!} smallTicker />
</Text>
<Text
color={transaction.status === TransactionStatus.Successful ? 'successful-label' : 'status-error'}
color={(() => {
switch (transaction.status) {
case TransactionStatus.Successful:
return 'successful-label'
case TransactionStatus.Pending:
return 'status-warning'
case TransactionStatus.Failed:
default:
return 'status-error'
}
})()}
size="small"
weight="bold"
>
{transaction.status === TransactionStatus.Successful ? (
<span>{t('account.transaction.successful', 'Successful')}</span>
) : (
<span>{t('account.transaction.failed', 'Failed')}</span>
)}
{(() => {
switch (transaction.status) {
case TransactionStatus.Successful:
return <span>{t('account.transaction.successful', 'Successful')}</span>
case TransactionStatus.Pending:
return <span>{t('account.transaction.pending', 'Pending')}</span>
case TransactionStatus.Failed:
default:
return <span>{t('account.transaction.failed', 'Failed')}</span>
}
})()}
</Text>
</Box>
</StyledCardBody>
Expand Down
2 changes: 1 addition & 1 deletion src/app/lib/getAccountBalanceWithFallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ function* getBalanceGRPC(address: string) {
delegations: null,
debonding: null,
total: null,
nonce: account.general?.nonce?.toString() ?? '0',
}
}

export function* getAccountBalanceWithFallback(address: string) {
const { getAccount } = yield* call(getExplorerAPIs)

try {
const account: Account = yield* call(getAccount, address)
return account
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,11 +1,171 @@
import * as React from 'react'
import { render } from '@testing-library/react'
import { render, screen } from '@testing-library/react'

import { TransactionHistory } from '..'
import { configureAppStore } from '../../../../../../store/configureStore'
import { Provider, useDispatch } from 'react-redux'
import { DeepPartialRootState, RootState } from '../../../../../../types/RootState'
import { Transaction, TransactionStatus, TransactionType } from 'app/state/transaction/types'

jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useDispatch: jest.fn(),
}))

const renderCmp = (store: ReturnType<typeof configureAppStore>) =>
render(
<Provider store={store}>
<TransactionHistory />
</Provider>,
)

const getPendingTx = (hash: string): Transaction => ({
hash,
type: TransactionType.StakingTransfer,
from: 'oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk',
amount: 1n.toString(),
to: 'oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuww',
status: undefined,
fee: undefined,
level: undefined,
round: undefined,
runtimeId: undefined,
runtimeName: undefined,
timestamp: undefined,
nonce: undefined,
})

const getTx = ({ hash = '', nonce = 0n, status = TransactionStatus.Successful } = {}): Transaction => ({
...getPendingTx(hash),
status,
nonce: nonce.toString(),
})

const getState = ({
accountNonce = 0n,
pendingLocalTxs = [],
accountTxs = [],
}: { accountNonce?: bigint; pendingLocalTxs?: Transaction[]; accountTxs?: Transaction[] } = {}) => {
const state: DeepPartialRootState = {
account: {
loading: false,
address: 'oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk',
available: 100000000000n.toString(),
delegations: null,
debonding: null,
total: null,
transactions: [...accountTxs],
accountError: undefined,
transactionsError: undefined,
pendingTransactions: {
local: [...pendingLocalTxs],
testnet: [],
mainnet: [],
},
nonce: accountNonce.toString(),
},
staking: {
delegations: [],
debondingDelegations: [],
},
wallet: {
selectedWallet: 'oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk',
wallets: {
oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk: {
address: 'oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk',
},
},
},
}
return configureAppStore(state as Partial<RootState>)
}

describe('<TransactionHistory />', () => {
it.skip('should match snapshot', () => {
const component = render(<TransactionHistory />)
expect(component.container.firstChild).toMatchSnapshot()
beforeEach(() => {
// Ignore dispatches to fetch account from AccountPage
jest.mocked(useDispatch).mockImplementation(() => jest.fn())
})

it('should not display any pending or completed txs', async () => {
renderCmp(getState())

expect(() => screen.getByTestId('pending-txs')).toThrow()
expect(() => screen.getByTestId('completed-txs')).toThrow()

expect(screen.queryByText('account.summary.someTxsInPendingState')).not.toBeInTheDocument()
expect(await screen.findByText('account.summary.noTransactionFound')).toBeInTheDocument()
})

it('should display pending txs alert and no transactions', async () => {
renderCmp(getState({ accountNonce: 1n }))

expect(() => screen.getByTestId('pending-txs')).toThrow()
expect(() => screen.getByTestId('completed-txs')).toThrow()

expect(await screen.findByText('account.summary.someTxsInPendingState')).toBeInTheDocument()
expect(await screen.findByText('account.summary.noTransactionFound')).toBeInTheDocument()
expect(await screen.findByRole('link')).toHaveAttribute(
'href',
'http://localhost:9001/data/accounts/detail/oasis1qz0k5q8vjqvu4s4nwxyj406ylnflkc4vrcjghuwk',
)
})

it('should display pending txs alert with single pending tx and no completed transactions', async () => {
renderCmp(getState({ accountNonce: 0n, pendingLocalTxs: [getPendingTx('txHash1')] }))

expect(screen.getByTestId('pending-txs').childElementCount).toBe(1)
expect(() => screen.getByTestId('completed-txs')).toThrow()

expect(await screen.findByText('account.summary.someTxsInPendingState')).toBeInTheDocument()
expect(await screen.findByText('account.summary.noTransactionFound')).toBeInTheDocument()
expect(await screen.findByText('txHash1')).toBeInTheDocument()
})

it('should display single pending and completed tx', async () => {
renderCmp(
getState({
accountNonce: 2n,
accountTxs: [getTx({ hash: 'txHash1', nonce: 0n })],
pendingLocalTxs: [getPendingTx('txHash2')],
}),
)

expect(screen.getByTestId('pending-txs').childElementCount).toBe(1)
expect(screen.getByTestId('completed-txs').childElementCount).toBe(1)

expect(await screen.findByText('txHash1')).toBeInTheDocument()
expect(await screen.findByText('txHash2')).toBeInTheDocument()
})

it('should not display pending section in case of failed tx', async () => {
renderCmp(
getState({
accountNonce: 1n,
accountTxs: [getTx({ hash: 'txHash1', nonce: 0n, status: TransactionStatus.Failed })],
}),
)

expect(() => screen.getByTestId('pending-txs')).toThrow()
expect(screen.getByTestId('completed-txs').childElementCount).toBe(1)

expect(await screen.findByText('txHash1')).toBeInTheDocument()

expect(() => screen.getByText('account.summary.someTxsInPendingState')).toThrow()
})

it('should not display pending section on initial load', async () => {
renderCmp(
getState({
accountNonce: 1n,
accountTxs: [getTx({ hash: 'txHash1', nonce: 0n, status: TransactionStatus.Successful })],
}),
)

expect(() => screen.getByTestId('pending-txs')).toThrow()
expect(screen.getByTestId('completed-txs').childElementCount).toBe(1)

expect(await screen.findByText('txHash1')).toBeInTheDocument()

expect(() => screen.getByText('account.summary.someTxsInPendingState')).toThrow()
})
})
Loading

0 comments on commit 7ca812c

Please sign in to comment.