Skip to content

Commit 9af6491

Browse files
authored
feat: asset historical transactions
1 parent 0e74802 commit 9af6491

File tree

7 files changed

+370
-1
lines changed

7 files changed

+370
-1
lines changed

src/features/assets/components/asset-details.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { Badge } from '@/features/common/components/badge'
2929
import { AssetMedia } from './asset-media'
3030
import { AssetTraits } from './asset-traits'
3131
import { AssetMetadata } from './asset-metadata'
32+
import { AssetTransactionHistory } from './asset-transaction-history'
3233

3334
type Props = {
3435
asset: Asset
@@ -156,7 +157,9 @@ export function AssetDetails({ asset }: Props) {
156157
<Card className={cn('p-4')}>
157158
<CardContent className={cn('text-sm space-y-2')}>
158159
<h1 className={cn('text-2xl text-primary font-bold')}>{assetTransactionsLabel}</h1>
159-
<div className={cn('border-solid border-2 grid p-4')}>{/* <TransactionsTable transactions={asset.transactions} /> */}</div>
160+
<div className={cn('border-solid border-2 grid p-4')}>
161+
<AssetTransactionHistory assetIndex={asset.id} />
162+
</div>
160163
</CardContent>
161164
</Card>
162165
</>
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { LazyLoadDataTable } from '@/features/common/components/lazy-load-data-table'
2+
import { AssetIndex } from '../data/types'
3+
import { Transaction, TransactionType } from '@/features/transactions/models'
4+
import { DisplayAlgo } from '@/features/common/components/display-algo'
5+
import { DisplayAssetAmount } from '@/features/common/components/display-asset-amount'
6+
import { cn } from '@/features/common/utils'
7+
import { TransactionLink } from '@/features/transactions/components/transaction-link'
8+
import { ellipseAddress } from '@/utils/ellipse-address'
9+
import { ColumnDef } from '@tanstack/react-table'
10+
import { useFetchNextAssetTransactionsPage } from '../data/asset-transaction-history'
11+
12+
type Props = {
13+
assetIndex: AssetIndex
14+
}
15+
16+
export function AssetTransactionHistory({ assetIndex }: Props) {
17+
const fetchNextPage = useFetchNextAssetTransactionsPage(assetIndex)
18+
19+
return <LazyLoadDataTable columns={transactionsTableColumns} fetchNextPage={fetchNextPage} />
20+
}
21+
22+
const transactionsTableColumns: ColumnDef<Transaction>[] = [
23+
{
24+
header: 'Transaction Id',
25+
accessorKey: 'id',
26+
cell: (c) => {
27+
const value = c.getValue<string>()
28+
return <TransactionLink transactionId={value} short={true} />
29+
},
30+
},
31+
{
32+
accessorKey: 'sender',
33+
header: 'From',
34+
cell: (c) => ellipseAddress(c.getValue<string>()),
35+
},
36+
{
37+
header: 'To',
38+
accessorFn: (transaction) => {
39+
if (transaction.type === TransactionType.Payment || transaction.type === TransactionType.AssetTransfer)
40+
return ellipseAddress(transaction.receiver)
41+
if (transaction.type === TransactionType.ApplicationCall) return transaction.applicationId
42+
if (transaction.type === TransactionType.AssetConfig) return transaction.assetId
43+
if (transaction.type === TransactionType.AssetFreeze) return ellipseAddress(transaction.address)
44+
},
45+
},
46+
{
47+
accessorKey: 'type',
48+
header: 'Type',
49+
},
50+
{
51+
header: 'Amount',
52+
accessorFn: (transaction) => transaction,
53+
cell: (c) => {
54+
const transaction = c.getValue<Transaction>()
55+
if (transaction.type === TransactionType.Payment) return <DisplayAlgo className={cn('justify-center')} amount={transaction.amount} />
56+
if (transaction.type === TransactionType.AssetTransfer)
57+
return <DisplayAssetAmount amount={transaction.amount} asset={transaction.asset} />
58+
},
59+
},
60+
]
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { AssetIndex } from '../data/types'
2+
import { indexer } from '@/features/common/data'
3+
import { TransactionResult, TransactionSearchResults } from '@algorandfoundation/algokit-utils/types/indexer'
4+
import { useMemo } from 'react'
5+
import { JotaiStore } from '@/features/common/data/types'
6+
import { fetchTransactionsAtomBuilder, transactionResultsAtom } from '@/features/transactions/data'
7+
import { atomEffect } from 'jotai-effect'
8+
import { atom, useStore } from 'jotai'
9+
10+
const fetchAssetTransactionResults = async (assetIndex: AssetIndex, pageSize: number, nextPageToken?: string) => {
11+
const results = (await indexer
12+
.searchForTransactions()
13+
.assetID(assetIndex)
14+
.nextToken(nextPageToken ?? '')
15+
.limit(pageSize)
16+
.do()) as TransactionSearchResults
17+
return {
18+
transactionResults: results.transactions,
19+
nextPageToken: results['next-token'],
20+
} as const
21+
}
22+
23+
const syncEffectBuilder = (transactionResults: TransactionResult[]) => {
24+
return atomEffect((_, set) => {
25+
;(async () => {
26+
try {
27+
set(transactionResultsAtom, (prev) => {
28+
const next = new Map(prev)
29+
transactionResults.forEach((transactionResult) => {
30+
next.set(transactionResult.id, transactionResult)
31+
})
32+
return next
33+
})
34+
} catch (e) {
35+
// Ignore any errors as there is nothing to sync
36+
}
37+
})()
38+
})
39+
}
40+
41+
const fetchAssetTransactionsAtomBuilder = (store: JotaiStore, assetIndex: AssetIndex, pageSize: number, nextPageToken?: string) => {
42+
return atom(async (get) => {
43+
const { transactionResults, nextPageToken: newNextPageToken } = await fetchAssetTransactionResults(assetIndex, pageSize, nextPageToken)
44+
45+
get(syncEffectBuilder(transactionResults))
46+
47+
const transactions = await get(fetchTransactionsAtomBuilder(store, transactionResults))
48+
49+
return {
50+
rows: transactions,
51+
nextPageToken: newNextPageToken,
52+
}
53+
})
54+
}
55+
56+
export const useFetchNextAssetTransactionsPage = (assetIndex: AssetIndex) => {
57+
const store = useStore()
58+
59+
return useMemo(() => {
60+
return (pageSize: number, nextPageToken?: string) => fetchAssetTransactionsAtomBuilder(store, assetIndex, pageSize, nextPageToken)
61+
}, [store, assetIndex])
62+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from './lazy-load-data-table'
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import SvgChevronLeft from '@/features/common/components/icons/chevron-left'
2+
import SvgChevronRight from '@/features/common/components/icons/chevron-right'
3+
import { Button } from '@/features/common/components/button'
4+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/features/common/components/select'
5+
6+
interface Props {
7+
pageSize: number
8+
setPageSize: (pageSize: number) => void
9+
currentPage: number
10+
nextPageEnabled: boolean
11+
nextPage: () => void
12+
previousPageEnabled: boolean
13+
previousPage: () => void
14+
}
15+
16+
const pageSizeOptions = [10, 20, 30, 40, 50]
17+
18+
export function LazyLoadDataTablePagination({
19+
pageSize,
20+
setPageSize,
21+
currentPage,
22+
nextPageEnabled,
23+
nextPage,
24+
previousPageEnabled,
25+
previousPage,
26+
}: Props) {
27+
return (
28+
<div className="mt-2 flex items-center justify-between">
29+
<div className="flex w-full">
30+
<div className="flex shrink grow basis-0 items-center justify-start space-x-2">
31+
<p className="text-sm font-medium">Rows per page</p>
32+
<Select
33+
value={`${pageSize}`}
34+
onValueChange={(value) => {
35+
setPageSize(Number(value))
36+
}}
37+
>
38+
<SelectTrigger className="h-8 w-[70px]">
39+
<SelectValue placeholder={pageSize} />
40+
</SelectTrigger>
41+
<SelectContent side="top">
42+
{pageSizeOptions.map((pageSize) => (
43+
<SelectItem key={pageSize} value={`${pageSize}`}>
44+
{pageSize}
45+
</SelectItem>
46+
))}
47+
</SelectContent>
48+
</Select>
49+
</div>
50+
<div className="flex shrink grow basis-0 items-center justify-center text-sm font-medium">Page {currentPage}</div>
51+
<div className="flex shrink grow basis-0 items-center justify-end space-x-2">
52+
<Button variant="outline" className="size-8 p-0" onClick={() => previousPage()} disabled={!previousPageEnabled}>
53+
<span className="sr-only">Go to previous page</span>
54+
<SvgChevronLeft className="size-4" />
55+
</Button>
56+
<Button variant="outline" className="size-8 p-0" onClick={() => nextPage()} disabled={!nextPageEnabled}>
57+
<span className="sr-only">Go to next page</span>
58+
<SvgChevronRight className="size-4" />
59+
</Button>
60+
</div>
61+
</div>
62+
</div>
63+
)
64+
}
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table'
2+
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/features/common/components/table'
3+
import { useCallback, useMemo, useState } from 'react'
4+
import { Atom } from 'jotai'
5+
import { DataPage, loadablePaginationBuilder } from '../../data/loadable-pagination-builder'
6+
import { LazyLoadDataTablePagination } from './lazy-load-data-table-pagination'
7+
import { Loader2 as Loader } from 'lucide-react'
8+
9+
interface Props<TData, TValue> {
10+
columns: ColumnDef<TData, TValue>[]
11+
fetchNextPage: (pageSize: number, nextPageToken?: string) => Atom<Promise<DataPage<TData>>>
12+
}
13+
14+
export function LazyLoadDataTable<TData, TValue>({ columns, fetchNextPage }: Props<TData, TValue>) {
15+
const [pageSize, setPageSize] = useState(10)
16+
const { useLoadablePage } = useMemo(
17+
() =>
18+
loadablePaginationBuilder({
19+
pageSize,
20+
fetchNextPage,
21+
}),
22+
[pageSize, fetchNextPage]
23+
)
24+
const [currentPage, setCurrentPage] = useState<number>(1)
25+
const loadablePage = useLoadablePage(currentPage)
26+
27+
const nextPage = useCallback(() => {
28+
setCurrentPage((prev) => prev + 1)
29+
}, [])
30+
31+
const previousPage = useCallback(() => {
32+
setCurrentPage((prev) => (prev > 1 ? prev - 1 : prev))
33+
}, [])
34+
35+
const setPageSizeAndResetCurrentPage = useCallback((newPageSize: number) => {
36+
setPageSize(newPageSize)
37+
setCurrentPage(1)
38+
}, [])
39+
40+
const page = useMemo(() => (loadablePage.state === 'hasData' ? loadablePage.data : undefined), [loadablePage])
41+
42+
const table = useReactTable({
43+
data: page?.rows ?? [],
44+
columns,
45+
getCoreRowModel: getCoreRowModel(),
46+
manualPagination: true,
47+
})
48+
49+
return (
50+
<div>
51+
<div className="grid rounded-md border">
52+
<Table>
53+
<TableHeader>
54+
{table.getHeaderGroups().map((headerGroup) => (
55+
<TableRow key={headerGroup.id}>
56+
{headerGroup.headers.map((header) => {
57+
return (
58+
<TableHead key={header.id}>
59+
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
60+
</TableHead>
61+
)
62+
})}
63+
</TableRow>
64+
))}
65+
</TableHeader>
66+
<TableBody>
67+
{loadablePage.state === 'loading' && (
68+
<TableRow>
69+
<TableCell colSpan={columns.length}>
70+
<div className="flex flex-col items-center">
71+
<Loader className="size-10 animate-spin" />
72+
</div>
73+
</TableCell>
74+
</TableRow>
75+
)}
76+
{loadablePage.state === 'hasError' && (
77+
<TableRow>
78+
<TableCell colSpan={columns.length}>Failed to load data.</TableCell>
79+
</TableRow>
80+
)}
81+
{loadablePage.state === 'hasData' &&
82+
table.getRowModel().rows?.length &&
83+
table.getRowModel().rows.map((row) => (
84+
<TableRow key={row.id} data-state={row.getIsSelected() && 'selected'}>
85+
{row.getVisibleCells().map((cell) => (
86+
<TableCell key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</TableCell>
87+
))}
88+
</TableRow>
89+
))}
90+
{loadablePage.state === 'hasData' && table.getRowModel().rows?.length === 0 && (
91+
<TableRow>
92+
<TableCell colSpan={columns.length} className="h-24 text-center">
93+
No results.
94+
</TableCell>
95+
</TableRow>
96+
)}
97+
</TableBody>
98+
</Table>
99+
</div>
100+
<LazyLoadDataTablePagination
101+
pageSize={pageSize}
102+
setPageSize={setPageSizeAndResetCurrentPage}
103+
currentPage={currentPage}
104+
nextPageEnabled={!!page?.nextPageToken && page.rows.length === pageSize}
105+
nextPage={nextPage}
106+
previousPageEnabled={currentPage > 1}
107+
previousPage={previousPage}
108+
/>
109+
</div>
110+
)
111+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { Atom, atom, useAtomValue, useStore } from 'jotai'
2+
import { atomEffect } from 'jotai-effect'
3+
import { loadable } from 'jotai/utils'
4+
import { JotaiStore } from './types'
5+
import { useMemo } from 'react'
6+
7+
export type DataPage<TData> = {
8+
rows: TData[]
9+
nextPageToken?: string
10+
}
11+
12+
type LoadablePaginationBuilderInput<TData> = {
13+
pageSize: number
14+
fetchNextPage: (pageSize: number, nextPageToken?: string) => Atom<Promise<DataPage<TData>>>
15+
}
16+
17+
export function loadablePaginationBuilder<TData>({ pageSize, fetchNextPage }: LoadablePaginationBuilderInput<TData>) {
18+
const rawDataPagesAtom = atom<DataPage<TData>[]>([])
19+
20+
const syncEffectBuilder = ({ rows, nextPageToken }: { rows: TData[]; nextPageToken?: string }) => {
21+
return atomEffect((_, set) => {
22+
;(async () => {
23+
try {
24+
set(rawDataPagesAtom, (prev) => {
25+
return Array.from(prev).concat([{ rows, nextPageToken }])
26+
})
27+
} catch (e) {
28+
// Ignore any errors as there is nothing to sync
29+
}
30+
})()
31+
})
32+
}
33+
34+
const getPageAtomBuilder = (store: JotaiStore, pageSize: number, pageNumber: number) => {
35+
return atom(async (get) => {
36+
const index = pageNumber - 1
37+
const cache = store.get(rawDataPagesAtom)
38+
39+
if (index < cache.length) {
40+
return cache[index] satisfies DataPage<TData>
41+
}
42+
43+
const currentNextPageToken = cache[cache.length - 1]?.nextPageToken
44+
const { rows, nextPageToken } = await get(fetchNextPage(pageSize, currentNextPageToken))
45+
46+
get(syncEffectBuilder({ rows, nextPageToken }))
47+
48+
return {
49+
rows: rows,
50+
nextPageToken,
51+
} satisfies DataPage<TData>
52+
})
53+
}
54+
55+
const usePageAtom = (pageSize: number, pageNumber: number) => {
56+
const store = useStore()
57+
58+
return useMemo(() => {
59+
return getPageAtomBuilder(store, pageSize, pageNumber)
60+
}, [store, pageSize, pageNumber])
61+
}
62+
63+
const useLoadablePage = (pageNumber: number) => {
64+
return useAtomValue(loadable(usePageAtom(pageSize, pageNumber)))
65+
}
66+
67+
return { useLoadablePage } as const
68+
}

0 commit comments

Comments
 (0)