From ed0acacc1da5ea31ae247c124caef7a8984e07db Mon Sep 17 00:00:00 2001 From: Hoang Dinh Date: Mon, 29 Apr 2024 11:38:18 +1000 Subject: [PATCH] feat: transaction group --- src/App.routes.tsx | 17 +- .../blocks/components/transactions.tsx | 10 +- src/features/blocks/data/block.ts | 49 +- src/features/blocks/mappers/index.ts | 15 +- src/features/blocks/models/index.ts | 8 +- src/features/common/mappers/index.ts | 16 + src/features/common/models/index.ts | 6 + .../groups/components/group-details.tsx | 67 + src/features/groups/components/group-link.tsx | 26 + .../groups/components/group-visual-tabs.tsx | 36 + src/features/groups/data/core.ts | 4 + src/features/groups/data/group.ts | 56 + src/features/groups/data/index.ts | 1 + src/features/groups/data/types.ts | 11 + src/features/groups/mappers/index.ts | 14 + src/features/groups/models/index.ts | 12 + src/features/groups/pages/group-page.test.tsx | 131 ++ src/features/groups/pages/group-page.tsx | 42 + ...EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA.html} | 73 +- ...4VYR4ZDZ3RXIET5CNYQVJUO5OXXPMHAMJCCQ.html} | 17 +- ...CFLIJNMBETT5QNKZURSLIONJBTJFALGYOAUA.html} | 8 +- ...CT2IGCG2FWMUGJEUYYK3KFKNSYRNAXU2ARUA.html} | 7 +- .../__snapshots__/group-graph.group-1.html | 1837 +++++++++++++++++ ...7MUVY3J67KOR5TV34OBTDDEQTDET2UFM7KTQ.html} | 7 +- ...UMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ.html} | 7 +- ...HBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA.html} | 8 +- .../app-call-transaction-details.tsx | 4 +- .../components/app-call-transaction-info.tsx | 2 +- .../asset-config-transaction-details.tsx | 4 +- .../asset-config-transaction-info.tsx | 2 +- .../asset-freeze-transaction-details.tsx | 4 +- .../asset-freeze-transaction-info.tsx | 2 +- .../asset-transfer-transaction-details.tsx | 4 +- .../asset-transfer-transaction-info.tsx | 2 +- .../components/inner-transaction-link.tsx | 2 +- .../key-reg-transaction-details.tsx | 4 +- .../components/key-reg-transaction-info.tsx | 2 +- .../payment-transaction-details.tsx | 4 +- .../components/payment-transaction-info.tsx | 2 +- .../components/transaction-info.tsx | 7 +- .../components/transaction-view-tabs.tsx | 42 - .../components/transaction-visual-tabs.tsx | 42 + ...l.test.tsx => transactions-graph.test.tsx} | 86 +- ...view-visual.tsx => transactions-graph.tsx} | 36 +- ...-view-table.tsx => transactions-table.tsx} | 16 +- .../transactions/pages/group-page.tsx | 381 ---- .../pages/transaction-page.test.tsx | 40 +- src/routes/urls.ts | 9 +- src/tests/builders/group-result-builder.ts | 19 + src/tests/object-mother/group-result.ts | 8 + src/tests/object-mother/transaction-result.ts | 40 + 51 files changed, 2651 insertions(+), 598 deletions(-) create mode 100644 src/features/common/mappers/index.ts create mode 100644 src/features/common/models/index.ts create mode 100644 src/features/groups/components/group-details.tsx create mode 100644 src/features/groups/components/group-link.tsx create mode 100644 src/features/groups/components/group-visual-tabs.tsx create mode 100644 src/features/groups/data/core.ts create mode 100644 src/features/groups/data/group.ts create mode 100644 src/features/groups/data/index.ts create mode 100644 src/features/groups/data/types.ts create mode 100644 src/features/groups/mappers/index.ts create mode 100644 src/features/groups/models/index.ts create mode 100644 src/features/groups/pages/group-page.test.tsx create mode 100644 src/features/groups/pages/group-page.tsx rename src/features/transactions/components/__snapshots__/{application-transaction-view-visual.INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA.html => application-transaction-graph.INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA.html} (98%) rename src/features/transactions/components/__snapshots__/{application-transaction-view-visual.KMNBSQ4ZFX252G7S4VYR4ZDZ3RXIET5CNYQVJUO5OXXPMHAMJCCQ.html => application-transaction-graph.KMNBSQ4ZFX252G7S4VYR4ZDZ3RXIET5CNYQVJUO5OXXPMHAMJCCQ.html} (97%) rename src/features/transactions/components/__snapshots__/{asset-transfer-view-visual.563MNGEL2OF4IBA7CFLIJNMBETT5QNKZURSLIONJBTJFALGYOAUA.html => asset-transfer-graph.563MNGEL2OF4IBA7CFLIJNMBETT5QNKZURSLIONJBTJFALGYOAUA.html} (94%) rename src/features/transactions/components/__snapshots__/{asset-transfer-view-visual.JBDSQEI37W5KWPQICT2IGCG2FWMUGJEUYYK3KFKNSYRNAXU2ARUA.html => asset-transfer-graph.JBDSQEI37W5KWPQICT2IGCG2FWMUGJEUYYK3KFKNSYRNAXU2ARUA.html} (95%) create mode 100644 src/features/transactions/components/__snapshots__/group-graph.group-1.html rename src/features/transactions/components/__snapshots__/{key-reg-view-visual.VE767RE4HGQM7GFC7MUVY3J67KOR5TV34OBTDDEQTDET2UFM7KTQ.html => key-reg-graph.VE767RE4HGQM7GFC7MUVY3J67KOR5TV34OBTDDEQTDET2UFM7KTQ.html} (91%) rename src/features/transactions/components/__snapshots__/{payment-transaction-view-visual.FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ.html => payment-transaction-graph.FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ.html} (96%) rename src/features/transactions/components/__snapshots__/{payment-transaction-view-visual.ILDCD5Z64CYSLEZIHBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA.html => payment-transaction-graph.ILDCD5Z64CYSLEZIHBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA.html} (95%) delete mode 100644 src/features/transactions/components/transaction-view-tabs.tsx create mode 100644 src/features/transactions/components/transaction-visual-tabs.tsx rename src/features/transactions/components/{transaction-view-visual.test.tsx => transactions-graph.test.tsx} (62%) rename src/features/transactions/components/{transaction-view-visual.tsx => transactions-graph.tsx} (95%) rename src/features/transactions/components/{transaction-view-table.tsx => transactions-table.tsx} (81%) delete mode 100644 src/features/transactions/pages/group-page.tsx create mode 100644 src/tests/builders/group-result-builder.ts create mode 100644 src/tests/object-mother/group-result.ts diff --git a/src/App.routes.tsx b/src/App.routes.tsx index 5303c41e8..925d4feb1 100644 --- a/src/App.routes.tsx +++ b/src/App.routes.tsx @@ -4,7 +4,7 @@ import { Urls } from './routes/urls' import { evalTemplates } from './routes/templated-route' import { TransactionPage, transactionPageTitle } from './features/transactions/pages/transaction-page' import { ExplorePage, explorePageTitle } from './features/explore/pages/explore-page' -import { GroupPage } from './features/transactions/pages/group-page' +import { GroupPage } from './features/groups/pages/group-page' import { ErrorPage } from './features/common/pages/error-page' import { BlockPage, blockPageTitle } from './features/blocks/pages/block-page' import { InnerTransactionPage } from './features/transactions/pages/inner-transaction-page' @@ -53,14 +53,19 @@ export const routes = evalTemplates([ }, ], }, - { - template: Urls.Explore.Group.ById, - element: , - }, { template: Urls.Explore.Block.ById, - element: , errorElement: , + children: [ + { + template: Urls.Explore.Block.ById, + element: , + }, + { + template: Urls.Explore.Block.ById.Group.ById, + element: , + }, + ], }, { template: Urls.Explore.Account.ById, diff --git a/src/features/blocks/components/transactions.tsx b/src/features/blocks/components/transactions.tsx index 6f2bd99b0..b66a061aa 100644 --- a/src/features/blocks/components/transactions.tsx +++ b/src/features/blocks/components/transactions.tsx @@ -1,11 +1,11 @@ import { DisplayAlgo } from '@/features/common/components/display-algo' import { ellipseAddress } from '@/utils/ellipse-address' -import { ellipseId } from '@/utils/ellipse-id' import { Transaction, TransactionType } from '@/features/transactions/models' import { ColumnDef } from '@tanstack/react-table' import { DataTable } from '@/features/common/components/data-table' import { TransactionLink } from '@/features/transactions/components/transaction-link' import { DisplayAssetAmount } from '@/features/common/components/display-asset-amount' +import { GroupLink } from '@/features/groups/components/group-link' type Props = { transactions: Transaction[] @@ -19,7 +19,11 @@ export const columns: ColumnDef[] = [ }, { header: 'Group ID', - accessorFn: (transaction) => ellipseId(transaction.group), + accessorFn: (transaction) => transaction, + cell: (c) => { + const transaction = c.getValue() + return transaction.group ? : undefined + }, }, { header: 'From', @@ -50,7 +54,7 @@ export const columns: ColumnDef[] = [ if (value.type === TransactionType.AssetTransfer) { return } - return <> + return undefined }, }, ] diff --git a/src/features/blocks/data/block.ts b/src/features/blocks/data/block.ts index 0f68e8673..f566c00a3 100644 --- a/src/features/blocks/data/block.ts +++ b/src/features/blocks/data/block.ts @@ -9,6 +9,8 @@ import { useMemo } from 'react' import { loadable } from 'jotai/utils' import { blockResultsAtom, syncedRoundAtom } from './core' import { BlockResult, Round } from './types' +import { groupResultsAtom } from '@/features/groups/data/core' +import { GroupId, GroupResult } from '@/features/groups/data/types' const nextRoundAvailableAtomBuilder = (store: JotaiStore, round: Round) => { // This atom conditionally subscribes to updates on the syncedRoundAtom @@ -19,33 +21,50 @@ const nextRoundAvailableAtomBuilder = (store: JotaiStore, round: Round) => { }) } -const fetchBlockResultAtomBuilder = (round: Round) => { +export const fetchBlockResultAtomBuilder = (round: Round) => { return atom(async (_get) => { return await indexer .lookupBlock(round) .do() .then((result) => { + const [transactionIds, groupResults] = ((result.transactions ?? []) as TransactionResult[]).reduce( + (acc, t) => { + acc[0].push(t.id) + if (t.group) { + const group: GroupResult = acc[1].get(t.group) ?? { + id: t.group, + round: result.round as number, + timestamp: new Date(result.timestamp * 1000).toISOString(), + transactionIds: [], + } + group.transactionIds.push(t.id) + acc[1].set(t.group, group) + } + return acc + }, + [[], new Map()] as [string[], Map] + ) + return [ { round: result.round as number, timestamp: new Date(result.timestamp * 1000).toISOString(), - transactionIds: result.transactions?.map((t: TransactionResult) => t.id) ?? [], + transactionIds, } as BlockResult, (result.transactions ?? []) as TransactionResult[], + groupResults, ] as const }) }) } -const getBlockAtomBuilder = (store: JotaiStore, round: Round) => { - const fetchBlockResultAtom = fetchBlockResultAtomBuilder(round) - - const syncEffect = atomEffect((get, set) => { +export const syncBlockAtomEffectBuilder = (fetchBlockResultAtom: ReturnType) => { + return atomEffect((get, set) => { ;(async () => { try { - const [blockResult, transactionResults] = await get(fetchBlockResultAtom) + const [blockResult, transactionResults, groupResults] = await get(fetchBlockResultAtom) - if (transactionResults && transactionResults.length > 0) { + if (transactionResults.length > 0) { set(transactionResultsAtom, (prev) => { transactionResults.forEach((t) => { prev.set(t.id, t) @@ -54,6 +73,15 @@ const getBlockAtomBuilder = (store: JotaiStore, round: Round) => { }) } + if (groupResults.size > 0) { + set(groupResultsAtom, (prev) => { + groupResults.forEach((g) => { + prev.set(g.id, g) + }) + return prev + }) + } + set(blockResultsAtom, (prev) => { return prev.set(blockResult.round, blockResult) }) @@ -62,6 +90,11 @@ const getBlockAtomBuilder = (store: JotaiStore, round: Round) => { } })() }) +} + +const getBlockAtomBuilder = (store: JotaiStore, round: Round) => { + const fetchBlockResultAtom = fetchBlockResultAtomBuilder(round) + const syncEffect = syncBlockAtomEffectBuilder(fetchBlockResultAtom) return atom(async (get) => { const blockResults = store.get(blockResultsAtom) diff --git a/src/features/blocks/mappers/index.ts b/src/features/blocks/mappers/index.ts index 444affeed..9cca97327 100644 --- a/src/features/blocks/mappers/index.ts +++ b/src/features/blocks/mappers/index.ts @@ -1,22 +1,13 @@ -import { Transaction, TransactionSummary, TransactionType } from '@/features/transactions/models' +import { Transaction, TransactionSummary } from '@/features/transactions/models' import { Block, BlockSummary, CommonBlockProperties } from '../models' import { BlockResult } from '../data/types' +import { asTransactionsSummary } from '@/features/common/mappers' const asCommonBlock = (block: BlockResult, transactions: Pick[]): CommonBlockProperties => { return { round: block.round, timestamp: block.timestamp, - transactionsSummary: { - count: transactions.length, - countByType: Array.from( - transactions - .reduce((acc, transaction) => { - const count = (acc.get(transaction.type) || 0) + 1 - return new Map([...acc, [transaction.type, count]]) - }, new Map()) - .entries() - ), - }, + transactionsSummary: asTransactionsSummary(transactions), } } diff --git a/src/features/blocks/models/index.ts b/src/features/blocks/models/index.ts index ca7d2c332..8aa1f8d86 100644 --- a/src/features/blocks/models/index.ts +++ b/src/features/blocks/models/index.ts @@ -1,9 +1,5 @@ -import { Transaction, TransactionSummary, TransactionType } from '@/features/transactions/models' - -export type TransactionsSummary = { - count: number - countByType: [TransactionType, number][] -} +import { TransactionsSummary } from '@/features/common/models' +import { Transaction, TransactionSummary } from '@/features/transactions/models' export type CommonBlockProperties = { round: number diff --git a/src/features/common/mappers/index.ts b/src/features/common/mappers/index.ts new file mode 100644 index 000000000..6c06f169c --- /dev/null +++ b/src/features/common/mappers/index.ts @@ -0,0 +1,16 @@ +import { Transaction, TransactionType } from '@/features/transactions/models' +import { TransactionsSummary } from '../models' + +export const asTransactionsSummary = (transactions: Pick[]): TransactionsSummary => { + return { + count: transactions.length, + countByType: Array.from( + transactions + .reduce((acc, transaction) => { + const count = (acc.get(transaction.type) || 0) + 1 + return new Map([...acc, [transaction.type, count]]) + }, new Map()) + .entries() + ), + } +} diff --git a/src/features/common/models/index.ts b/src/features/common/models/index.ts new file mode 100644 index 000000000..b1aaf6fce --- /dev/null +++ b/src/features/common/models/index.ts @@ -0,0 +1,6 @@ +import { TransactionType } from '@/features/transactions/models' + +export type TransactionsSummary = { + count: number + countByType: [TransactionType, number][] +} diff --git a/src/features/groups/components/group-details.tsx b/src/features/groups/components/group-details.tsx new file mode 100644 index 000000000..eaf37dc72 --- /dev/null +++ b/src/features/groups/components/group-details.tsx @@ -0,0 +1,67 @@ +import { Card, CardContent } from '@/features/common/components/card' +import { Group } from '../models' +import { cn } from '@/features/common/utils' +import { DescriptionList } from '@/features/common/components/description-list' +import { useMemo } from 'react' +import { Badge } from '@/features/common/components/badge' +import { dateFormatter } from '@/utils/format' +import { BlockLink } from '@/features/blocks/components/block-link' +import { GroupVisualTabs } from './group-visual-tabs' + +type Props = { + group: Group +} + +export const groupIdLabel = 'Group ID' +export const blockLabel = 'Block' +export const transactionsLabel = 'Transactions' +export const timestampLabel = 'Timestamp' + +export function GroupDetails({ group }: Props) { + const groupItems = useMemo( + () => [ + { + dt: groupIdLabel, + dd: group.id, + }, + { + dt: blockLabel, + dd: , + }, + { + dt: transactionsLabel, + dd: ( + <> + {group.transactionsSummary.count} + {group.transactionsSummary.countByType.map(([type, count]) => ( + + {type}={count} + + ))} + + ), + }, + { + dt: timestampLabel, + dd: dateFormatter.asLongDateTime(new Date(group.timestamp)), + }, + ], + [group.id, group.round, group.timestamp, group.transactionsSummary.count, group.transactionsSummary.countByType] + ) + + return ( +
+ + + + + + + +

{transactionsLabel}

+
+ +
+
+ ) +} diff --git a/src/features/groups/components/group-link.tsx b/src/features/groups/components/group-link.tsx new file mode 100644 index 000000000..bfd846410 --- /dev/null +++ b/src/features/groups/components/group-link.tsx @@ -0,0 +1,26 @@ +import { Round } from '@/features/blocks/data/types' +import { cn } from '@/features/common/utils' +import { TemplatedNavLink } from '@/features/routing/components/templated-nav-link/templated-nav-link' +import { Urls } from '@/routes/urls' +import { PropsWithChildren } from 'react' +import { GroupId } from '../data/types' +import { ellipseId } from '@/utils/ellipse-id' + +type Props = PropsWithChildren<{ + round: Round + groupId: GroupId + short?: boolean + className?: string +}> + +export function GroupLink({ round, groupId, short = false, className, children }: Props) { + return ( + + {children ? children : short ? ellipseId(groupId) : groupId} + + ) +} diff --git a/src/features/groups/components/group-visual-tabs.tsx b/src/features/groups/components/group-visual-tabs.tsx new file mode 100644 index 000000000..ebc7a73fa --- /dev/null +++ b/src/features/groups/components/group-visual-tabs.tsx @@ -0,0 +1,36 @@ +import { cn } from '@/features/common/utils' +import { OverflowAutoTabsContent, Tabs, TabsList, TabsTrigger } from '@/features/common/components/tabs' +import { Group } from '../models' +import { TransactionsGraph } from '@/features/transactions/components/transactions-graph' +import { TransactionsTable } from '@/features/transactions/components/transactions-table' + +type Props = { + group: Group +} + +const graphTabId = 'graph' +const tableTabId = 'table' +export const groupVisual = 'View Group' +export const groupVisualGraphLabel = 'Graph' +export const groupVisualTableLabel = 'Table' + +export function GroupVisualTabs({ group }: Props) { + return ( + + + + {groupVisualGraphLabel} + + + {groupVisualTableLabel} + + + + + + + + + + ) +} diff --git a/src/features/groups/data/core.ts b/src/features/groups/data/core.ts new file mode 100644 index 000000000..dd694b293 --- /dev/null +++ b/src/features/groups/data/core.ts @@ -0,0 +1,4 @@ +import { atom } from 'jotai' +import { GroupId, GroupResult } from './types' + +export const groupResultsAtom = atom>(new Map()) diff --git a/src/features/groups/data/group.ts b/src/features/groups/data/group.ts new file mode 100644 index 000000000..5c79eea76 --- /dev/null +++ b/src/features/groups/data/group.ts @@ -0,0 +1,56 @@ +import { Round } from '@/features/blocks/data/types' +import { JotaiStore } from '@/features/common/data/types' +import { atom, useAtomValue, useStore } from 'jotai' +import { GroupId } from './types' +import { asGroup } from '../mappers' +import { useMemo } from 'react' +import { loadable } from 'jotai/utils' +import { fetchTransactionResultsAtomBuilder, fetchTransactionsAtomBuilder } from '@/features/transactions/data' +import { groupResultsAtom } from './core' +import { fetchBlockResultAtomBuilder, syncBlockAtomEffectBuilder } from '@/features/blocks/data' +import { invariant } from '@/utils/invariant' + +const fetchGroupResultAtomBuilder = (fetchBlockResultAtom: ReturnType, groupId: GroupId) => { + return atom(async (get) => { + const [blockResult, transactionResults, groupResults] = await get(fetchBlockResultAtom) + const groupResult = groupResults.get(groupId) + invariant(groupResult, `Transaction group ${groupId} not found in round ${blockResult.round}`) + const groupTransactions = transactionResults.filter((t) => t.group === groupResult.id) + return [groupResult, groupTransactions] as const + }) +} + +const getGroupAtomBuilder = (store: JotaiStore, round: Round, groupId: GroupId) => { + const fetchBlockResultAtom = fetchBlockResultAtomBuilder(round) + const fetchGroupResultAtom = fetchGroupResultAtomBuilder(fetchBlockResultAtom, groupId) + const syncEffect = syncBlockAtomEffectBuilder(fetchBlockResultAtom) + + return atom(async (get) => { + const groupResults = store.get(groupResultsAtom) + const cachedGroupResult = groupResults.get(groupId) + if (cachedGroupResult) { + const transactions = await get( + fetchTransactionsAtomBuilder(store, fetchTransactionResultsAtomBuilder(store, cachedGroupResult.transactionIds)) + ) + return asGroup(cachedGroupResult, transactions) + } + + get(syncEffect) + + const [groupResult, transactionResults] = await get(fetchGroupResultAtom) + const transactions = await get(fetchTransactionsAtomBuilder(store, transactionResults)) + return asGroup(groupResult, transactions) + }) +} + +const useGroupAtom = (round: Round, groupId: GroupId) => { + const store = useStore() + + return useMemo(() => { + return getGroupAtomBuilder(store, round, groupId) + }, [store, round, groupId]) +} + +export const useLoadableGroup = (round: Round, groupId: GroupId) => { + return useAtomValue(loadable(useGroupAtom(round, groupId))) +} diff --git a/src/features/groups/data/index.ts b/src/features/groups/data/index.ts new file mode 100644 index 000000000..ff648b3b5 --- /dev/null +++ b/src/features/groups/data/index.ts @@ -0,0 +1 @@ +export * from './group' diff --git a/src/features/groups/data/types.ts b/src/features/groups/data/types.ts new file mode 100644 index 000000000..2f781bca4 --- /dev/null +++ b/src/features/groups/data/types.ts @@ -0,0 +1,11 @@ +import { Round } from '@/features/blocks/data/types' +import { TransactionId } from '@/features/transactions/data/types' + +export type GroupId = string + +export type GroupResult = { + id: GroupId + timestamp: string + round: Round + transactionIds: TransactionId[] +} diff --git a/src/features/groups/mappers/index.ts b/src/features/groups/mappers/index.ts new file mode 100644 index 000000000..b00493053 --- /dev/null +++ b/src/features/groups/mappers/index.ts @@ -0,0 +1,14 @@ +import { Group } from '../models' +import { GroupResult } from '../data/types' +import { asTransactionsSummary } from '@/features/common/mappers' +import { Transaction } from '@/features/transactions/models' + +export const asGroup = (groupResult: GroupResult, transactions: Transaction[]): Group => { + return { + id: groupResult.id, + round: groupResult.round, + transactions, + timestamp: groupResult.timestamp, + transactionsSummary: asTransactionsSummary(transactions), + } +} diff --git a/src/features/groups/models/index.ts b/src/features/groups/models/index.ts new file mode 100644 index 000000000..56904b0c0 --- /dev/null +++ b/src/features/groups/models/index.ts @@ -0,0 +1,12 @@ +import { Transaction } from '@/features/transactions/models' +import { GroupId } from '../data/types' +import { Round } from '@/features/blocks/data/types' +import { TransactionsSummary } from '@/features/common/models' + +export type Group = { + id: GroupId + round: Round + transactions: Transaction[] + timestamp: string + transactionsSummary: TransactionsSummary +} diff --git a/src/features/groups/pages/group-page.test.tsx b/src/features/groups/pages/group-page.test.tsx new file mode 100644 index 000000000..e103f1903 --- /dev/null +++ b/src/features/groups/pages/group-page.test.tsx @@ -0,0 +1,131 @@ +import { executeComponentTest } from '@/tests/test-component' +import { getByRole, render, waitFor } from '@/tests/testing-library' +import { useParams } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { GroupPage, blockInvalidRoundMessage, blockNotFoundMessage, groupFailedToLoadMessage } from './group-page' +import { indexer } from '@/features/common/data' +import { HttpError } from '@/tests/errors' +import { groupResultMother } from '@/tests/object-mother/group-result' +import { createStore } from 'jotai' +import { groupResultsAtom } from '../data/core' +import { descriptionListAssertion } from '@/tests/assertions/description-list-assertion' +import { blockLabel, groupIdLabel, timestampLabel, transactionsLabel } from '../components/group-details' +import { transactionResultMother } from '@/tests/object-mother/transaction-result' +import { assetResultMother } from '@/tests/object-mother/asset-result' +import { algoAssetResult, assetResultsAtom } from '@/features/assets/data/core' +import { AssetResult } from '@algorandfoundation/algokit-utils/types/indexer' +import { transactionResultsAtom } from '@/features/transactions/data' +import { groupVisual, groupVisualGraphLabel, groupVisualTableLabel } from '../components/group-visual-tabs' +import { tableAssertion } from '@/tests/assertions/table-assertion' + +describe('block-page', () => { + describe('when rending a group using an invalid round number', () => { + it('should display invalid round message', () => { + vi.mocked(useParams).mockImplementation(() => ({ round: 'invalid-id', groupId: 'some-id' })) + + return executeComponentTest( + () => render(), + async (component) => { + await waitFor(() => expect(component.getByText(blockInvalidRoundMessage)).toBeTruthy()) + } + ) + }) + }) + + describe('when rending a group with a round number that does not exist', () => { + it('should display not found message', () => { + vi.mocked(useParams).mockImplementation(() => ({ round: '123456', groupId: 'some-id' })) + vi.mocked(indexer.lookupBlock(0).do).mockImplementation(() => Promise.reject(new HttpError('boom', 404))) + + return executeComponentTest( + () => render(), + async (component) => { + await waitFor(() => expect(component.getByText(blockNotFoundMessage)).toBeTruthy()) + } + ) + }) + }) + + describe('when rending a group that fails to load the block', () => { + it('should display failed to load message', () => { + vi.mocked(useParams).mockImplementation(() => ({ round: '123456', groupId: 'some-id' })) + vi.mocked(indexer.lookupBlock(0).do).mockImplementation(() => Promise.reject({})) + + return executeComponentTest( + () => render(), + async (component) => { + await waitFor(() => expect(component.getByText(groupFailedToLoadMessage)).toBeTruthy()) + } + ) + }) + }) + + describe('when rending a group', () => { + const transactionResult1 = transactionResultMother['mainnet-INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA']().build() + const transactionResult2 = transactionResultMother['mainnet-7VSN7QTNBT7X4V5JH2ONKTJYF6VSQSE2H5J7VTDWFCJGSJED3QUA']().build() + const transactionResults = [transactionResult1, transactionResult2] + const assets = [ + assetResultMother['mainnet-31566704']().build(), + assetResultMother['mainnet-386195940']().build(), + assetResultMother['mainnet-408898501']().build(), + ] + const group = groupResultMother + .groupWithTransactions(transactionResults) + .withId('/oRSr2uMFemQhwQliJO18b64Nl1QIkjA39ZszRCeSCI=') + .withRound(36591812) + .withTimestamp('2024-03-01T01:07:53Z') + .build() + + it('should be rendered with the correct data', () => { + vi.mocked(useParams).mockImplementation(() => ({ round: group.round.toString(), groupId: group.id })) + const myStore = createStore() + myStore.set(groupResultsAtom, new Map([[group.id, group]])) + myStore.set(transactionResultsAtom, new Map(transactionResults.map((x) => [x.id, x]))) + myStore.set( + assetResultsAtom, + new Map([[algoAssetResult.index, algoAssetResult], ...assets.map<[number, AssetResult]>((a) => [a.index, a])]) + ) + + return executeComponentTest( + () => render(, undefined, myStore), + async (component, user) => { + await waitFor(() => + descriptionListAssertion({ + container: component.container, + items: [ + { term: groupIdLabel, description: '/oRSr2uMFemQhwQliJO18b64Nl1QIkjA39ZszRCeSCI=' }, + { term: blockLabel, description: '36591812' }, + { term: timestampLabel, description: 'Fri, 01 March 2024 01:07:53' }, + { term: transactionsLabel, description: '2Application Call=2' }, + ], + }) + ) + + const groupVisualTabList = component.getByRole('tablist', { name: groupVisual }) + expect(groupVisualTabList).toBeTruthy() + expect( + component.getByRole('tabpanel', { name: groupVisualGraphLabel }).getAttribute('data-state'), + 'Visual tab should be active' + ).toBe('active') + + // After click on the Table tab + await user.click(getByRole(groupVisualTabList, 'tab', { name: groupVisualTableLabel })) + const tableViewTab = component.getByRole('tabpanel', { name: groupVisualTableLabel }) + await waitFor(() => expect(tableViewTab.getAttribute('data-state'), 'Table tab should be active').toBe('active')) + tableAssertion({ + container: tableViewTab, + // This table has 10+ row, we only test the first 2 rows + rows: [ + { + cells: ['INDQXWQ...', 'AACC...EN4A', '1201559522', 'Application Call'], + }, + { + cells: ['Inner 1', 'AACC...EN4A', '2PIF...RNMM', 'Payment', '2.770045'], + }, + ], + }) + } + ) + }) + }) +}) diff --git a/src/features/groups/pages/group-page.tsx b/src/features/groups/pages/group-page.tsx new file mode 100644 index 000000000..94e5774de --- /dev/null +++ b/src/features/groups/pages/group-page.tsx @@ -0,0 +1,42 @@ +import { useRequiredParam } from '@/features/common/hooks/use-required-param' +import { UrlParams } from '@/routes/urls' +import { useLoadableGroup } from '../data' +import { is404 } from '@/utils/error' +import { RenderLoadable } from '@/features/common/components/render-loadable' +import { GroupDetails } from '../components/group-details' +import { cn } from '@/features/common/utils' +import { invariant } from '@/utils/invariant' +import { isInteger } from '@/utils/is-integer' + +export const groupPageTitle = 'Transaction Group' +export const blockNotFoundMessage = 'Block not found' +export const blockInvalidRoundMessage = 'Round is invalid' +export const groupFailedToLoadMessage = 'Transaction group failed to load' + +const transformError = (e: Error) => { + if (is404(e)) { + return new Error(blockNotFoundMessage) + } + + // eslint-disable-next-line no-console + console.error(e) + return new Error(groupFailedToLoadMessage) +} + +export function GroupPage() { + const { round } = useRequiredParam(UrlParams.Round) + invariant(isInteger(round), blockInvalidRoundMessage) + const { groupId } = useRequiredParam(UrlParams.GroupId) + + const roundNumber = parseInt(round, 10) + const loadableGroup = useLoadableGroup(roundNumber, groupId) + + return ( +
+

{groupPageTitle}

+ + {(group) => } + +
+ ) +} diff --git a/src/features/transactions/components/__snapshots__/application-transaction-view-visual.INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA.html b/src/features/transactions/components/__snapshots__/application-transaction-graph.INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA.html similarity index 98% rename from src/features/transactions/components/__snapshots__/application-transaction-view-visual.INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA.html rename to src/features/transactions/components/__snapshots__/application-transaction-graph.INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA.html index aca20e68e..d1d103699 100644 --- a/src/features/transactions/components/__snapshots__/application-transaction-view-visual.INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA.html +++ b/src/features/transactions/components/__snapshots__/application-transaction-graph.INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA.html @@ -179,7 +179,12 @@ class="inline" style="margin-left: 16px;" > - INDQXWQ... + + INDQXWQ... + @@ -638,11 +640,10 @@ style="margin-left: 28px;" > - Inner - 3 + Inner 3 @@ -972,11 +971,10 @@ style="margin-left: 28px;" > - Inner - 5 + Inner 5 @@ -1304,11 +1300,10 @@ style="margin-left: 28px;" > - Inner - 7 + Inner 7 @@ -1648,11 +1641,10 @@ style="margin-left: 28px;" > - Inner - 9 + Inner 9 @@ -1742,6 +1734,5 @@
-
\ No newline at end of file diff --git a/src/features/transactions/components/__snapshots__/application-transaction-view-visual.KMNBSQ4ZFX252G7S4VYR4ZDZ3RXIET5CNYQVJUO5OXXPMHAMJCCQ.html b/src/features/transactions/components/__snapshots__/application-transaction-graph.KMNBSQ4ZFX252G7S4VYR4ZDZ3RXIET5CNYQVJUO5OXXPMHAMJCCQ.html similarity index 97% rename from src/features/transactions/components/__snapshots__/application-transaction-view-visual.KMNBSQ4ZFX252G7S4VYR4ZDZ3RXIET5CNYQVJUO5OXXPMHAMJCCQ.html rename to src/features/transactions/components/__snapshots__/application-transaction-graph.KMNBSQ4ZFX252G7S4VYR4ZDZ3RXIET5CNYQVJUO5OXXPMHAMJCCQ.html index b2402d2e6..336fd65b7 100644 --- a/src/features/transactions/components/__snapshots__/application-transaction-view-visual.KMNBSQ4ZFX252G7S4VYR4ZDZ3RXIET5CNYQVJUO5OXXPMHAMJCCQ.html +++ b/src/features/transactions/components/__snapshots__/application-transaction-graph.KMNBSQ4ZFX252G7S4VYR4ZDZ3RXIET5CNYQVJUO5OXXPMHAMJCCQ.html @@ -111,7 +111,12 @@ class="inline" style="margin-left: 16px;" > - KMNBSQ4... + + KMNBSQ4... +
diff --git a/src/features/transactions/components/__snapshots__/asset-transfer-view-visual.563MNGEL2OF4IBA7CFLIJNMBETT5QNKZURSLIONJBTJFALGYOAUA.html b/src/features/transactions/components/__snapshots__/asset-transfer-graph.563MNGEL2OF4IBA7CFLIJNMBETT5QNKZURSLIONJBTJFALGYOAUA.html similarity index 94% rename from src/features/transactions/components/__snapshots__/asset-transfer-view-visual.563MNGEL2OF4IBA7CFLIJNMBETT5QNKZURSLIONJBTJFALGYOAUA.html rename to src/features/transactions/components/__snapshots__/asset-transfer-graph.563MNGEL2OF4IBA7CFLIJNMBETT5QNKZURSLIONJBTJFALGYOAUA.html index a5b5cb6f1..2f65d3258 100644 --- a/src/features/transactions/components/__snapshots__/asset-transfer-view-visual.563MNGEL2OF4IBA7CFLIJNMBETT5QNKZURSLIONJBTJFALGYOAUA.html +++ b/src/features/transactions/components/__snapshots__/asset-transfer-graph.563MNGEL2OF4IBA7CFLIJNMBETT5QNKZURSLIONJBTJFALGYOAUA.html @@ -60,7 +60,12 @@ class="inline" style="margin-left: 16px;" > - 563MNGE... + + 563MNGE... + @@ -124,6 +129,5 @@ -
\ No newline at end of file diff --git a/src/features/transactions/components/__snapshots__/asset-transfer-view-visual.JBDSQEI37W5KWPQICT2IGCG2FWMUGJEUYYK3KFKNSYRNAXU2ARUA.html b/src/features/transactions/components/__snapshots__/asset-transfer-graph.JBDSQEI37W5KWPQICT2IGCG2FWMUGJEUYYK3KFKNSYRNAXU2ARUA.html similarity index 95% rename from src/features/transactions/components/__snapshots__/asset-transfer-view-visual.JBDSQEI37W5KWPQICT2IGCG2FWMUGJEUYYK3KFKNSYRNAXU2ARUA.html rename to src/features/transactions/components/__snapshots__/asset-transfer-graph.JBDSQEI37W5KWPQICT2IGCG2FWMUGJEUYYK3KFKNSYRNAXU2ARUA.html index 40e5fbb9c..51aaadb68 100644 --- a/src/features/transactions/components/__snapshots__/asset-transfer-view-visual.JBDSQEI37W5KWPQICT2IGCG2FWMUGJEUYYK3KFKNSYRNAXU2ARUA.html +++ b/src/features/transactions/components/__snapshots__/asset-transfer-graph.JBDSQEI37W5KWPQICT2IGCG2FWMUGJEUYYK3KFKNSYRNAXU2ARUA.html @@ -77,7 +77,12 @@ class="inline" style="margin-left: 16px;" > - JBDSQEI... + + JBDSQEI... + diff --git a/src/features/transactions/components/__snapshots__/group-graph.group-1.html b/src/features/transactions/components/__snapshots__/group-graph.group-1.html new file mode 100644 index 000000000..06cfd96d2 --- /dev/null +++ b/src/features/transactions/components/__snapshots__/group-graph.group-1.html @@ -0,0 +1,1837 @@ +
+
+
+
+

+ AACC...EN4A +

+
+
+

+ 1201559522 +

+
+
+

+ 2PIF...RNMM +

+
+
+

+ 1002541853 +

+
+
+

+ FCHE...IYSM +

+
+
+

+ EOXL...GCE4 +

+
+
+

+ IWT4...4RVY +

+
+
+

+ 645869114 +

+
+
+

+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ + + + + +
+
+ + + +
+
+ App Call +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ + + + + +
+
+ + + +
+
+ Payment +
+ 2.770045 + + + + + +
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + + +
+
+ + + +
+
+ App Call +
+ + + + + +
+
+
+
+
+
+
+
+
+ +
+
+ + + + + +
+ + + +
+
+
+ Transfer +
+ 0.586582 + + USDC +
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+ + + + + +
+
+ + + +
+
+ Transfer +
+ 0.586582 + + USDC +
+
+ + + + + +
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + + +
+
+ + + +
+
+ App Call +
+ + + + + +
+
+
+
+
+
+
+
+
+ +
+
+ + + + + +
+ + + +
+
+
+ Transfer +
+ 146.5 + + LTBX +
+
+ + + + + +
+
+
+
+
+
+
+
+ +
+
+
+
+ + + + + +
+
+ + + +
+
+ Transfer +
+ 146.5 + + LTBX +
+
+ + + + + +
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + + +
+
+ + + +
+
+ App Call +
+ + + + + +
+
+
+
+
+
+
+
+
+ +
+
+ + + + + +
+ + + +
+
+
+ Transfer +
+ 0.00017647 + + goETH +
+
+ + + + + +
+
+
+
+
+
+
+ +
+
+
+
+ + + + + +
+
+ + + +
+
+ Transfer +
+ 0.00017647 + + goETH +
+
+ + + + + +
+
+
+
+
+
+ +
+
+
+
+
+ + + + + +
+
+ + + +
+
+ App Call +
+ + + + + +
+
+
+
+
+ +
+
+ + + + + +
+ + + +
+
+
+ Payment +
+ 2.78626 + + + + + +
+
+ + + + + +
+
+
+
+
+ +
+
+ + + + + +
+ + + +
+
+
+
+ 0 + + + + + +
+
+
+
+
+
+
+
+
+
+
+ +
+
+ + + + + +
+
+ + + +
+
+ App Call +
+ + + + + +
+
+
+
+
+
+
+
+
+
\ No newline at end of file diff --git a/src/features/transactions/components/__snapshots__/key-reg-view-visual.VE767RE4HGQM7GFC7MUVY3J67KOR5TV34OBTDDEQTDET2UFM7KTQ.html b/src/features/transactions/components/__snapshots__/key-reg-graph.VE767RE4HGQM7GFC7MUVY3J67KOR5TV34OBTDDEQTDET2UFM7KTQ.html similarity index 91% rename from src/features/transactions/components/__snapshots__/key-reg-view-visual.VE767RE4HGQM7GFC7MUVY3J67KOR5TV34OBTDDEQTDET2UFM7KTQ.html rename to src/features/transactions/components/__snapshots__/key-reg-graph.VE767RE4HGQM7GFC7MUVY3J67KOR5TV34OBTDDEQTDET2UFM7KTQ.html index e01561178..1749f0933 100644 --- a/src/features/transactions/components/__snapshots__/key-reg-view-visual.VE767RE4HGQM7GFC7MUVY3J67KOR5TV34OBTDDEQTDET2UFM7KTQ.html +++ b/src/features/transactions/components/__snapshots__/key-reg-graph.VE767RE4HGQM7GFC7MUVY3J67KOR5TV34OBTDDEQTDET2UFM7KTQ.html @@ -60,7 +60,12 @@ class="inline" style="margin-left: 16px;" > - VE767RE... + + VE767RE... +
diff --git a/src/features/transactions/components/__snapshots__/payment-transaction-view-visual.FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ.html b/src/features/transactions/components/__snapshots__/payment-transaction-graph.FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ.html similarity index 96% rename from src/features/transactions/components/__snapshots__/payment-transaction-view-visual.FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ.html rename to src/features/transactions/components/__snapshots__/payment-transaction-graph.FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ.html index ef4003340..75352763b 100644 --- a/src/features/transactions/components/__snapshots__/payment-transaction-view-visual.FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ.html +++ b/src/features/transactions/components/__snapshots__/payment-transaction-graph.FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ.html @@ -77,7 +77,12 @@ class="inline" style="margin-left: 16px;" > - FBORGSD... + + FBORGSD... +
diff --git a/src/features/transactions/components/__snapshots__/payment-transaction-view-visual.ILDCD5Z64CYSLEZIHBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA.html b/src/features/transactions/components/__snapshots__/payment-transaction-graph.ILDCD5Z64CYSLEZIHBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA.html similarity index 95% rename from src/features/transactions/components/__snapshots__/payment-transaction-view-visual.ILDCD5Z64CYSLEZIHBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA.html rename to src/features/transactions/components/__snapshots__/payment-transaction-graph.ILDCD5Z64CYSLEZIHBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA.html index f244b84d7..46e23e2ba 100644 --- a/src/features/transactions/components/__snapshots__/payment-transaction-view-visual.ILDCD5Z64CYSLEZIHBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA.html +++ b/src/features/transactions/components/__snapshots__/payment-transaction-graph.ILDCD5Z64CYSLEZIHBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA.html @@ -60,7 +60,12 @@ class="inline" style="margin-left: 16px;" > - ILDCD5Z... + + ILDCD5Z... +
@@ -142,6 +147,5 @@
-
\ No newline at end of file diff --git a/src/features/transactions/components/app-call-transaction-details.tsx b/src/features/transactions/components/app-call-transaction-details.tsx index fe46918ba..8daefc087 100644 --- a/src/features/transactions/components/app-call-transaction-details.tsx +++ b/src/features/transactions/components/app-call-transaction-details.tsx @@ -6,7 +6,7 @@ import { MultisigDetails } from './multisig-details' import { TransactionJson } from './transaction-json' import { TransactionNote } from './transaction-note' import { AppCallTransaction, InnerAppCallTransaction, SignatureType } from '../models' -import { TransactionViewTabs } from './transaction-view-tabs' +import { TransactionVisualTabs } from './transaction-visual-tabs' import { AppCallTransactionInfo } from './app-call-transaction-info' import { AppCallTransactionLogs } from './app-call-transaction-logs' @@ -21,7 +21,7 @@ export function AppCallTransactionDetails({ transaction }: Props) { - + {transaction.note && } {transaction.logs.length > 0 && } diff --git a/src/features/transactions/components/app-call-transaction-info.tsx b/src/features/transactions/components/app-call-transaction-info.tsx index b22e9b0e6..222c1bdd4 100644 --- a/src/features/transactions/components/app-call-transaction-info.tsx +++ b/src/features/transactions/components/app-call-transaction-info.tsx @@ -4,7 +4,7 @@ import { cn } from '@/features/common/utils' import { useMemo } from 'react' import { ColumnDef } from '@tanstack/react-table' import { DataTable } from '@/features/common/components/data-table' -import { transactionSenderLabel } from './transaction-view-table' +import { transactionSenderLabel } from './transactions-table' import { DescriptionList } from '@/features/common/components/description-list' import { ellipseAddress } from '@/utils/ellipse-address' import { AccountLink } from '@/features/accounts/components/account-link' diff --git a/src/features/transactions/components/asset-config-transaction-details.tsx b/src/features/transactions/components/asset-config-transaction-details.tsx index 86e4a316a..ca5c86ef0 100644 --- a/src/features/transactions/components/asset-config-transaction-details.tsx +++ b/src/features/transactions/components/asset-config-transaction-details.tsx @@ -6,7 +6,7 @@ import { TransactionJson } from './transaction-json' import { SignatureType, AssetConfigTransaction, InnerAssetConfigTransaction } from '../models' import { MultisigDetails } from './multisig-details' import { LogicsigDetails } from './logicsig-details' -import { TransactionViewTabs } from './transaction-view-tabs' +import { TransactionVisualTabs } from './transaction-visual-tabs' import { AssetConfigTransactionInfo } from './asset-config-transaction-info' type Props = { @@ -20,7 +20,7 @@ export function AssetConfigTransactionDetails({ transaction }: Props) { - + {transaction.note && } {transaction.signature?.type === SignatureType.Multi && } diff --git a/src/features/transactions/components/asset-config-transaction-info.tsx b/src/features/transactions/components/asset-config-transaction-info.tsx index 4db5b8d62..197b7688d 100644 --- a/src/features/transactions/components/asset-config-transaction-info.tsx +++ b/src/features/transactions/components/asset-config-transaction-info.tsx @@ -2,7 +2,7 @@ import { cn } from '@/features/common/utils' import { useMemo } from 'react' import { AssetConfigTransaction, InnerAssetConfigTransaction } from '../models' import { DescriptionList } from '@/features/common/components/description-list' -import { transactionSenderLabel } from './transaction-view-table' +import { transactionSenderLabel } from './transactions-table' import { AccountLink } from '@/features/accounts/components/account-link' import { isDefined } from '@/utils/is-defined' diff --git a/src/features/transactions/components/asset-freeze-transaction-details.tsx b/src/features/transactions/components/asset-freeze-transaction-details.tsx index e2f99b4b5..109430b43 100644 --- a/src/features/transactions/components/asset-freeze-transaction-details.tsx +++ b/src/features/transactions/components/asset-freeze-transaction-details.tsx @@ -7,7 +7,7 @@ import { AssetFreezeTransaction, InnerAssetFreezeTransaction, SignatureType } fr import { MultisigDetails } from './multisig-details' import { LogicsigDetails } from './logicsig-details' import { AssetFreezeTransactionInfo } from './asset-freeze-transaction-info' -import { TransactionViewTabs } from './transaction-view-tabs' +import { TransactionVisualTabs } from './transaction-visual-tabs' type AssetFreezeTransactionProps = { transaction: AssetFreezeTransaction | InnerAssetFreezeTransaction @@ -20,7 +20,7 @@ export function AssetFreezeTransactionDetails({ transaction }: AssetFreezeTransa - + {transaction.note && } {transaction.signature?.type === SignatureType.Multi && } diff --git a/src/features/transactions/components/asset-freeze-transaction-info.tsx b/src/features/transactions/components/asset-freeze-transaction-info.tsx index e26e32d00..a13d258f5 100644 --- a/src/features/transactions/components/asset-freeze-transaction-info.tsx +++ b/src/features/transactions/components/asset-freeze-transaction-info.tsx @@ -2,7 +2,7 @@ import { cn } from '@/features/common/utils' import { useMemo } from 'react' import { AssetFreezeTransaction, InnerAssetFreezeTransaction } from '../models' import { DescriptionList } from '@/features/common/components/description-list' -import { transactionSenderLabel } from './transaction-view-table' +import { transactionSenderLabel } from './transactions-table' import { AccountLink } from '@/features/accounts/components/account-link' type Props = { diff --git a/src/features/transactions/components/asset-transfer-transaction-details.tsx b/src/features/transactions/components/asset-transfer-transaction-details.tsx index 2a2a51e2d..af9efe352 100644 --- a/src/features/transactions/components/asset-transfer-transaction-details.tsx +++ b/src/features/transactions/components/asset-transfer-transaction-details.tsx @@ -7,7 +7,7 @@ import { AssetTransferTransaction, InnerAssetTransferTransaction, SignatureType import { MultisigDetails } from './multisig-details' import { LogicsigDetails } from './logicsig-details' import { AssetTransferTransactionInfo } from './asset-transfer-transaction-info' -import { TransactionViewTabs } from './transaction-view-tabs' +import { TransactionVisualTabs } from './transaction-visual-tabs' type AssetTransaferTransactionProps = { transaction: AssetTransferTransaction | InnerAssetTransferTransaction @@ -20,7 +20,7 @@ export function AssetTranserTransactionDetails({ transaction }: AssetTransaferTr - + {transaction.note && } {transaction.signature?.type === SignatureType.Multi && } diff --git a/src/features/transactions/components/asset-transfer-transaction-info.tsx b/src/features/transactions/components/asset-transfer-transaction-info.tsx index ca1ff7bea..d5d57978c 100644 --- a/src/features/transactions/components/asset-transfer-transaction-info.tsx +++ b/src/features/transactions/components/asset-transfer-transaction-info.tsx @@ -2,7 +2,7 @@ import { cn } from '@/features/common/utils' import { useMemo } from 'react' import { AssetTransferTransaction, AssetTransferTransactionSubType, InnerAssetTransferTransaction } from '../models' import { DescriptionList } from '@/features/common/components/description-list' -import { transactionSenderLabel, transactionReceiverLabel, transactionAmountLabel } from './transaction-view-table' +import { transactionSenderLabel, transactionReceiverLabel, transactionAmountLabel } from './transactions-table' import { DisplayAssetAmount } from '@/features/common/components/display-asset-amount' import { AccountLink } from '@/features/accounts/components/account-link' diff --git a/src/features/transactions/components/inner-transaction-link.tsx b/src/features/transactions/components/inner-transaction-link.tsx index 736d89e7f..9bba2fb6f 100644 --- a/src/features/transactions/components/inner-transaction-link.tsx +++ b/src/features/transactions/components/inner-transaction-link.tsx @@ -15,7 +15,7 @@ export function InnerTransactionLink({ innerTransactionId, className, children } urlTemplate={Urls.Explore.Transaction.ById.Inner.ById} urlParams={{ innerTransactionId: innerTransactionId }} > - {children ? children : innerTransactionId} + {children ? children : `Inner ${innerTransactionId}`} ) } diff --git a/src/features/transactions/components/key-reg-transaction-details.tsx b/src/features/transactions/components/key-reg-transaction-details.tsx index 08eaa4a3b..5e1256209 100644 --- a/src/features/transactions/components/key-reg-transaction-details.tsx +++ b/src/features/transactions/components/key-reg-transaction-details.tsx @@ -7,7 +7,7 @@ import { KeyRegTransaction, InnerKeyRegTransaction, SignatureType } from '../mod import { MultisigDetails } from './multisig-details' import { LogicsigDetails } from './logicsig-details' import { KeyRegTransactionInfo } from './key-reg-transaction-info' -import { TransactionViewTabs } from './transaction-view-tabs' +import { TransactionVisualTabs } from './transaction-visual-tabs' type KeyRegTransactionProps = { transaction: KeyRegTransaction | InnerKeyRegTransaction @@ -20,7 +20,7 @@ export function KeyRegTransactionDetails({ transaction }: KeyRegTransactionProps - + {transaction.note && } {transaction.signature?.type === SignatureType.Multi && } diff --git a/src/features/transactions/components/key-reg-transaction-info.tsx b/src/features/transactions/components/key-reg-transaction-info.tsx index 83489751b..1ae91b229 100644 --- a/src/features/transactions/components/key-reg-transaction-info.tsx +++ b/src/features/transactions/components/key-reg-transaction-info.tsx @@ -2,7 +2,7 @@ import { cn } from '@/features/common/utils' import { useMemo } from 'react' import { KeyRegTransaction, InnerKeyRegTransaction } from '../models' import { DescriptionList } from '@/features/common/components/description-list' -import { transactionSenderLabel } from './transaction-view-table' +import { transactionSenderLabel } from './transactions-table' import { AccountLink } from '@/features/accounts/components/account-link' import { isDefined } from '@/utils/is-defined' diff --git a/src/features/transactions/components/payment-transaction-details.tsx b/src/features/transactions/components/payment-transaction-details.tsx index d2a611a99..27bab7f83 100644 --- a/src/features/transactions/components/payment-transaction-details.tsx +++ b/src/features/transactions/components/payment-transaction-details.tsx @@ -7,7 +7,7 @@ import { SignatureType, PaymentTransaction, InnerPaymentTransaction } from '../m import { MultisigDetails } from './multisig-details' import { LogicsigDetails } from './logicsig-details' import { PaymentTransactionInfo } from './payment-transaction-info' -import { TransactionViewTabs } from './transaction-view-tabs' +import { TransactionVisualTabs } from './transaction-visual-tabs' type PaymentTransactionProps = { transaction: PaymentTransaction | InnerPaymentTransaction @@ -20,7 +20,7 @@ export function PaymentTransactionDetails({ transaction }: PaymentTransactionPro - + {transaction.note && } {transaction.signature?.type === SignatureType.Multi && } diff --git a/src/features/transactions/components/payment-transaction-info.tsx b/src/features/transactions/components/payment-transaction-info.tsx index b7c3e578f..3bcdb9f2b 100644 --- a/src/features/transactions/components/payment-transaction-info.tsx +++ b/src/features/transactions/components/payment-transaction-info.tsx @@ -2,7 +2,7 @@ import { cn } from '@/features/common/utils' import { DisplayAlgo } from '@/features/common/components/display-algo' import { useMemo } from 'react' import { InnerPaymentTransaction, PaymentTransaction } from '../models' -import { transactionAmountLabel, transactionReceiverLabel, transactionSenderLabel } from './transaction-view-table' +import { transactionAmountLabel, transactionReceiverLabel, transactionSenderLabel } from './transactions-table' import { DescriptionList } from '@/features/common/components/description-list' import { AccountLink } from '@/features/accounts/components/account-link' diff --git a/src/features/transactions/components/transaction-info.tsx b/src/features/transactions/components/transaction-info.tsx index c9803873d..f23f0298e 100644 --- a/src/features/transactions/components/transaction-info.tsx +++ b/src/features/transactions/components/transaction-info.tsx @@ -7,6 +7,7 @@ import { Transaction, SignatureType, InnerTransaction } from '../models' import { DescriptionList } from '@/features/common/components/description-list' import { Badge } from '@/features/common/components/badge' import { BlockLink } from '@/features/blocks/components/block-link' +import { GroupLink } from '@/features/groups/components/group-link' type Props = { transaction: Transaction | InnerTransaction @@ -49,11 +50,7 @@ export function TransactionInfo({ transaction }: Props) { ? [ { dt: transactionGroupLabel, - dd: ( - - {transaction.group} - - ), + dd: , }, ] : []), diff --git a/src/features/transactions/components/transaction-view-tabs.tsx b/src/features/transactions/components/transaction-view-tabs.tsx deleted file mode 100644 index 5344c3b7d..000000000 --- a/src/features/transactions/components/transaction-view-tabs.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { cn } from '@/features/common/utils' -import { TransactionViewTable } from './transaction-view-table' -import { TransactionViewVisual } from './transaction-view-visual' -import { InnerTransaction, Transaction } from '../models' -import { OverflowAutoTabsContent, Tabs, TabsList, TabsTrigger } from '@/features/common/components/tabs' - -type Props = { - transaction: Transaction | InnerTransaction -} - -const visualTransactionDetailsTabId = 'visual' -const tableTransactionDetailsTabId = 'table' -export const transactionDetailsLabel = 'View Transaction Details' -export const visualTransactionDetailsTabLabel = 'Visual' -export const tableTransactionDetailsTabLabel = 'Table' - -export function TransactionViewTabs({ transaction }: Props) { - return ( - - - - {visualTransactionDetailsTabLabel} - - - {tableTransactionDetailsTabLabel} - - - - - - - - - - ) -} diff --git a/src/features/transactions/components/transaction-visual-tabs.tsx b/src/features/transactions/components/transaction-visual-tabs.tsx new file mode 100644 index 000000000..af47ee859 --- /dev/null +++ b/src/features/transactions/components/transaction-visual-tabs.tsx @@ -0,0 +1,42 @@ +import { cn } from '@/features/common/utils' +import { TransactionsTable } from './transactions-table' +import { InnerTransaction, Transaction } from '../models' +import { OverflowAutoTabsContent, Tabs, TabsList, TabsTrigger } from '@/features/common/components/tabs' +import { TransactionsGraph } from './transactions-graph' + +type Props = { + transaction: Transaction | InnerTransaction +} + +const transactionVisualGraphTabId = 'visual' +const transactionVisualTableTabId = 'table' +export const transactionDetailsLabel = 'View Transaction Details' +export const transactionVisualGraphTabLabel = 'Graph' +export const transactionVisualTableTabLabel = 'Table' + +export function TransactionVisualTabs({ transaction }: Props) { + return ( + + + + {transactionVisualGraphTabLabel} + + + {transactionVisualTableTabLabel} + + + + + + + + + + ) +} diff --git a/src/features/transactions/components/transaction-view-visual.test.tsx b/src/features/transactions/components/transactions-graph.test.tsx similarity index 62% rename from src/features/transactions/components/transaction-view-visual.test.tsx rename to src/features/transactions/components/transactions-graph.test.tsx index b0982f7a8..43b575e69 100644 --- a/src/features/transactions/components/transaction-view-visual.test.tsx +++ b/src/features/transactions/components/transactions-graph.test.tsx @@ -1,14 +1,17 @@ import { transactionResultMother } from '@/tests/object-mother/transaction-result' import { describe, expect, it, vi } from 'vitest' -import { TransactionViewVisual } from './transaction-view-visual' import { executeComponentTest } from '@/tests/test-component' import { render, prettyDOM } from '@/tests/testing-library' -import { asAppCallTransaction, asAssetTransferTransaction, asPaymentTransaction } from '../mappers' +import { asAppCallTransaction, asAssetTransferTransaction, asPaymentTransaction, asTransaction } from '../mappers' import { AssetResult, TransactionResult } from '@algorandfoundation/algokit-utils/types/indexer' import { assetResultMother } from '@/tests/object-mother/asset-result' import { useParams } from 'react-router-dom' import { asAsset } from '@/features/assets/mappers' +import { TransactionsGraph } from './transactions-graph' import { asKeyRegTransaction } from '../mappers/key-reg-transaction-mappers' +import { asGroup } from '@/features/groups/mappers' +import { groupResultMother } from '@/tests/object-mother/group-result' +import { algoAssetResult } from '@/features/assets/data/core' // This file maintain the snapshot test for the TransactionViewVisual component // To add new test case: @@ -22,7 +25,7 @@ import { asKeyRegTransaction } from '../mappers/key-reg-transaction-mappers' const prettyDomMaxLength = 200000 -describe('payment-transaction-view-visual', () => { +describe('payment-transaction-graph', () => { describe.each([ transactionResultMother['mainnet-FBORGSDC4ULLWHWZUMUFIYQLSDC26HGLTFD7EATQDY37FHCIYBBQ']().build(), transactionResultMother['mainnet-ILDCD5Z64CYSLEZIHBG5DVME2ITJI2DIVZAPDPEWPCYMTRA5SVGA']().build(), @@ -31,10 +34,10 @@ describe('payment-transaction-view-visual', () => { const model = asPaymentTransaction(transactionResult) return executeComponentTest( - () => render(), + () => render(), async (component) => { expect(prettyDOM(component.container, prettyDomMaxLength, { highlight: false })).toMatchFileSnapshot( - `__snapshots__/payment-transaction-view-visual.${transactionResult.id}.html` + `__snapshots__/payment-transaction-graph.${transactionResult.id}.html` ) } ) @@ -42,7 +45,7 @@ describe('payment-transaction-view-visual', () => { }) }) -describe('asset-transfer-transaction-view-visual', () => { +describe('asset-transfer-transaction-graph', () => { describe.each([ { transactionResult: transactionResultMother['mainnet-JBDSQEI37W5KWPQICT2IGCG2FWMUGJEUYYK3KFKNSYRNAXU2ARUA']().build(), @@ -59,10 +62,10 @@ describe('asset-transfer-transaction-view-visual', () => { const transaction = asAssetTransferTransaction(transactionResult, asAsset(assetResult)) return executeComponentTest( - () => render(), + () => render(), async (component) => { expect(prettyDOM(component.container, prettyDomMaxLength, { highlight: false })).toMatchFileSnapshot( - `__snapshots__/asset-transfer-view-visual.${transaction.id}.html` + `__snapshots__/asset-transfer-graph.${transaction.id}.html` ) } ) @@ -71,7 +74,7 @@ describe('asset-transfer-transaction-view-visual', () => { ) }) -describe('application-call-view-visual', () => { +describe('application-call-graph', () => { describe.each([ { transactionResult: transactionResultMother['mainnet-KMNBSQ4ZFX252G7S4VYR4ZDZ3RXIET5CNYQVJUO5OXXPMHAMJCCQ']().build(), @@ -94,10 +97,10 @@ describe('application-call-view-visual', () => { const model = asAppCallTransaction(transactionResult, assetResults.map(asAsset)) return executeComponentTest( - () => render(), + () => render(), async (component) => { expect(prettyDOM(component.container, prettyDomMaxLength, { highlight: false })).toMatchFileSnapshot( - `__snapshots__/application-transaction-view-visual.${transactionResult.id}.html` + `__snapshots__/application-transaction-graph.${transactionResult.id}.html` ) } ) @@ -106,7 +109,7 @@ describe('application-call-view-visual', () => { ) }) -describe('key-reg-view-visual', () => { +describe('key-reg-graph', () => { describe.each([ { transactionResult: transactionResultMother['mainnet-VE767RE4HGQM7GFC7MUVY3J67KOR5TV34OBTDDEQTDET2UFM7KTQ']().build(), @@ -118,13 +121,68 @@ describe('key-reg-view-visual', () => { const model = asKeyRegTransaction(transactionResult) return executeComponentTest( - () => render(), + () => render(), async (component) => { expect(prettyDOM(component.container, prettyDomMaxLength, { highlight: false })).toMatchFileSnapshot( - `__snapshots__/key-reg-view-visual.${transactionResult.id}.html` + `__snapshots__/key-reg-graph.${transactionResult.id}.html` ) } ) }) }) }) + +describe('group-graph', () => { + describe.each([ + { + groupId: 'group-1', + transactionResults: [ + transactionResultMother['mainnet-INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA']().build(), + transactionResultMother['mainnet-7VSN7QTNBT7X4V5JH2ONKTJYF6VSQSE2H5J7VTDWFCJGSJED3QUA']().build(), + ], + assetResults: [ + assetResultMother['mainnet-31566704']().build(), + assetResultMother['mainnet-386195940']().build(), + assetResultMother['mainnet-408898501']().build(), + algoAssetResult, + ], + }, + ])( + 'when rendering transaction $transactionResult.id', + ({ + groupId, + transactionResults, + assetResults, + }: { + groupId: string + transactionResults: TransactionResult[] + assetResults: AssetResult[] + }) => { + it('should match snapshot', async () => { + const transactions = await Promise.all( + transactionResults.map((t) => + asTransaction(t, (assetId) => { + const assetResult = assetResults.find((a) => a.index === assetId) + if (!assetResult) { + throw new Error(`Could not find asset result ${assetId}`) + } + return asAsset(assetResult) + }) + ) + ) + const groupResult = groupResultMother.groupWithTransactions(transactionResults).withId(groupId).build() + + const group = asGroup(groupResult, transactions) + + return executeComponentTest( + () => render(), + async (component) => { + expect(prettyDOM(component.container, prettyDomMaxLength, { highlight: false })).toMatchFileSnapshot( + `__snapshots__/group-graph.${groupId}.html` + ) + } + ) + }) + } + ) +}) diff --git a/src/features/transactions/components/transaction-view-visual.tsx b/src/features/transactions/components/transactions-graph.tsx similarity index 95% rename from src/features/transactions/components/transaction-view-visual.tsx rename to src/features/transactions/components/transactions-graph.tsx index 3ff1e718a..b105b49fd 100644 --- a/src/features/transactions/components/transaction-view-visual.tsx +++ b/src/features/transactions/components/transactions-graph.tsx @@ -25,16 +25,16 @@ import { } from '../models' import { DisplayAlgo } from '@/features/common/components/display-algo' import { DescriptionList } from '@/features/common/components/description-list' -import { ellipseAddress } from '@/utils/ellipse-address' -import { flattenInnerTransactions } from '@/utils/flatten-inner-transactions' import { transactionIdLabel, transactionTypeLabel } from './transaction-info' -import { ellipseId } from '@/utils/ellipse-id' -import { transactionAmountLabel, transactionReceiverLabel, transactionSenderLabel } from './transaction-view-table' +import { transactionAmountLabel, transactionReceiverLabel, transactionSenderLabel } from './transactions-table' import { DisplayAssetAmount } from '@/features/common/components/display-asset-amount' import { InnerTransactionLink } from './inner-transaction-link' import { assetIdLabel } from './asset-config-transaction-info' import { assetFreezeAddressLabel, assetFreezeStatusLabel } from './asset-freeze-transaction-info' import { Badge } from '@/features/common/components/badge' +import { TransactionLink } from './transaction-link' +import { ellipseAddress } from '@/utils/ellipse-address' +import { flattenInnerTransactions } from '@/utils/flatten-inner-transactions' const graphConfig = { rowHeight: 40, @@ -94,9 +94,9 @@ function ConnectionToParent() { function TransactionId({ hasParent, transaction }: { hasParent: boolean; transaction: Transaction | InnerTransaction }) { const component = useMemo(() => { if ('innerId' in transaction) { - return Inner {transaction.innerId} + return } - return ellipseId(transaction.id) + return }, [transaction]) return ( @@ -482,7 +482,7 @@ function KeyRegTransactionToolTipContent({ transaction }: { transaction: KeyRegT ) } -type TransactionRowProps = { +type TransactionGraphProps = { transaction: Transaction | InnerTransaction hasParent?: boolean hasNextSibling?: boolean @@ -491,14 +491,14 @@ type TransactionRowProps = { indentLevel?: number verticalBars: (number | undefined)[] } -function TransactionRow({ +function TransactionGraph({ transaction, collaborators, hasParent = false, hasNextSibling = false, indentLevel, verticalBars, -}: TransactionRowProps) { +}: TransactionGraphProps) { const transactionRepresentation = useMemo(() => getTransactionRepresentation(transaction, collaborators), [collaborators, transaction]) const hasChildren = transaction.type === TransactionType.ApplicationCall && transaction.innerTransactions.length > 0 @@ -524,7 +524,10 @@ function TransactionRow({ return
} if (transactionRepresentation.type === 'point' && index > transactionRepresentation.from) return
- if (transactionRepresentation.type === 'selfLoop' && index > transactionRepresentation.from) return
+ // The `index > transactionRepresentation.from + 1` is here + // because a self-loop vector is renderred across 2 grid cells (see RenderTransactionSelfLoop). + // Therefore, we skip this cell so that we won't cause overflowing + if (transactionRepresentation.type === 'selfLoop' && index > transactionRepresentation.from + 1) return
if (index === transactionRepresentation.from) return ( @@ -553,7 +556,7 @@ function TransactionRow({ {hasChildren && transaction.innerTransactions.map((childTransaction, index, arr) => ( - flattenInnerTransactions(transaction), [transaction]) +export function TransactionsGraph({ transactions }: Props) { + const flattenedTransactions = useMemo(() => transactions.flatMap((transaction) => flattenInnerTransactions(transaction)), [transactions]) + const transactionCount = flattenedTransactions.length const collaborators: Collaborator[] = [ ...getTransactionsCollaborators(flattenedTransactions.map((t) => t.transaction)), @@ -679,7 +683,9 @@ export function TransactionViewVisual({ transaction }: Props) {
- + {transactions.map((transaction, index) => ( + + ))}
) } diff --git a/src/features/transactions/components/transaction-view-table.tsx b/src/features/transactions/components/transactions-table.tsx similarity index 81% rename from src/features/transactions/components/transaction-view-table.tsx rename to src/features/transactions/components/transactions-table.tsx index fe36a9df1..2aaccf9e1 100644 --- a/src/features/transactions/components/transaction-view-table.tsx +++ b/src/features/transactions/components/transactions-table.tsx @@ -4,25 +4,25 @@ import { DisplayAlgo } from '@/features/common/components/display-algo' import { ellipseAddress } from '@/utils/ellipse-address' import { FlattenedTransaction, flattenInnerTransactions } from '@/utils/flatten-inner-transactions' import { useMemo } from 'react' -import { ellipseId } from '@/utils/ellipse-id' import { DisplayAssetAmount } from '@/features/common/components/display-asset-amount' import { ColumnDef } from '@tanstack/react-table' import { DataTable } from '@/features/common/components/data-table' import { InnerTransactionLink } from './inner-transaction-link' +import { TransactionLink } from './transaction-link' const graphConfig = { indentationWidth: 20, } type Props = { - transaction: Transaction | InnerTransaction + transactions: Transaction[] | InnerTransaction[] } export const transactionSenderLabel = 'Sender' export const transactionReceiverLabel = 'Receiver' export const transactionAmountLabel = 'Amount' -export const tableColumns: ColumnDef[] = [ +export const transactionsTableColumns: ColumnDef[] = [ { header: 'Transaction Id', accessorFn: (item) => item, @@ -35,9 +35,9 @@ export const tableColumns: ColumnDef[] = [ }} > {'innerId' in transaction ? ( - Inner {transaction.innerId} + ) : ( - ellipseId(transaction.id) + )}
) @@ -75,8 +75,8 @@ export const tableColumns: ColumnDef[] = [ }, ] -export function TransactionViewTable({ transaction }: Props) { - const flattenedTransactions = useMemo(() => flattenInnerTransactions(transaction), [transaction]) +export function TransactionsTable({ transactions }: Props) { + const flattenedTransactions = useMemo(() => transactions.flatMap((transaction) => flattenInnerTransactions(transaction)), [transactions]) - return + return } diff --git a/src/features/transactions/pages/group-page.tsx b/src/features/transactions/pages/group-page.tsx deleted file mode 100644 index 0ee430174..000000000 --- a/src/features/transactions/pages/group-page.tsx +++ /dev/null @@ -1,381 +0,0 @@ -import SvgCircle from '@/features/common/components/svg/circle' -import SvgPointerLeft from '@/features/common/components/svg/pointer-left' -import SvgPointerRight from '@/features/common/components/svg/pointer-right' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/features/common/components/tooltip' -import { cn } from '@/features/common/utils' -import { fixedForwardRef } from '@/utils/fixed-forward-ref' -import { isDefined } from '@/utils/is-defined' -import { useMemo } from 'react' - -type Arrow = { - from: number - to: number - direction: 'leftToRight' | 'rightToLeft' | 'toSelf' -} - -function VerticalBars({ verticalBars }: { verticalBars: (number | undefined)[] }) { - // The side vertical bars when there are nested items - return (verticalBars ?? []) - .filter(isDefined) - .map((b, i) => ( -
- )) -} - -function ConnectionToParent() { - // The connection between this transaction and the parent - return ( -
- ) -} - -function TransactionName({ hasParent, name }: { hasParent: boolean; name: string }) { - return ( -
- {name} -
- ) -} - -function ConnectionToSibling() { - // The connection between this transaction and the next sibling - return ( -
- ) -} - -function ConnectionToChildren({ indentLevel }: { indentLevel: number | undefined }) { - // The connection between this transaction and the children - return ( -
- ) -} - -const DisplayArrow = fixedForwardRef(({ arrow, ...rest }: { arrow: Arrow }, ref?: React.LegacyRef) => { - return ( -
- -
- {arrow.direction === 'rightToLeft' && } -
- {arrow.direction === 'leftToRight' && } -
-
Payment
- -
- ) -}) - -const DisplaySelfTransaction = fixedForwardRef((props: object, ref?: React.LegacyRef) => { - return ( -
- -
- ) -}) - -type TransactionRowProps = { - transaction: Transaction - hasParent?: boolean - hasNextSibling?: boolean - hasChildren?: boolean - accounts: string[] - indentLevel?: number - verticalBars: (number | undefined)[] -} -function TransactionRow({ - transaction, - accounts, - hasParent = false, - hasNextSibling = false, - hasChildren = false, - indentLevel, - verticalBars, -}: TransactionRowProps) { - const arrow = useMemo(() => calcArrow(transaction, accounts), [accounts, transaction]) - - return ( - <> -
- -
- {hasParent && } - - {hasParent && hasNextSibling && } - {hasChildren && } -
-
- {accounts.map((_, index) => { - if (index < arrow.from || index > arrow.to) return
- if (index === arrow.from && index === arrow.to) - return ( - - - - - -
Transaction: {transaction.name}
-
-
- ) - if (index === arrow.from) - return ( - - - - - -
Transaction: {transaction.name}
-
-
- ) - else return null - })} - - {hasChildren && - transaction.transactions?.map((childTransaction, index, arr) => ( - 0} - hasParent={true} - hasNextSibling={index < arr.length - 1} - accounts={accounts} - indentLevel={indentLevel == null ? 0 : indentLevel + 1} - verticalBars={[...(verticalBars ?? []), hasNextSibling ? indentLevel ?? 0 : undefined]} - /> - ))} - - ) -} - -export function GroupPage() { - const group: Group = { - transactions: [ - { name: '7VSN...', sender: 'Account 4', receiver: 'Account 2' }, - { - name: 'NDQX...', - sender: 'Account 1', - receiver: 'Account 2', - transactions: [ - { name: 'Inner 1', sender: 'Account 1', receiver: 'Account 3' }, - { - name: 'Inner 2', - sender: 'Account 2', - receiver: 'Account 5', - transactions: [ - { name: 'Inner 3', sender: 'Account 5', receiver: 'Account 1' }, - { - name: 'Inner 11', - sender: 'Account 3', - receiver: 'Account 1', - transactions: [ - { name: 'Inner 10', sender: 'Account 2', receiver: 'Account 6' }, - { name: 'Inner 10', sender: 'Account 2', receiver: 'Account 6' }, - ], - }, - { - name: 'Inner 5', - sender: 'Account 3', - receiver: 'Account 1', - transactions: [ - { - name: 'Inner 10', - sender: 'Account 2', - receiver: 'Account 6', - transactions: [{ name: 'Inner 12', sender: 'Account 5', receiver: 'Account 6' }], - }, - ], - }, - { - name: 'Inner 6', - sender: 'Account 3', - receiver: 'Account 1', - }, - ], - }, - { name: 'Inner 9', sender: 'Account 2', receiver: 'Account 6' }, - { - name: 'Inner 4', - sender: 'Account 1', - receiver: 'Account 1', - transactions: [ - { name: 'Inner 6', sender: 'Account 3', receiver: 'Account 2' }, - { - name: 'Inner 7', - sender: 'Account 4', - receiver: 'Account 2', - }, - ], - }, - { name: 'Inner 8', sender: 'Account 5', receiver: 'Account 3' }, - ], - }, - ], - } - const { transactionCount, accounts } = extractSendersAndReceivers(group) - - return ( -
-
{/* The first header cell is empty */}
- {accounts.map((account, index) => ( -
-

{account}

-
- ))} - {/* The below div is for drawing the background dash lines */} -
-
-
-
-
- {accounts.map((_, index) => ( -
-
-
- ))} -
-
-
-
- {group.transactions.map((transaction, index, arr) => ( - 0} - hasParent={false} - hasNextSibling={index < arr.length - 1} - accounts={accounts} - verticalBars={[]} - /> - ))} -
- ) -} - -export type Group = { - transactions: Transaction[] -} - -export type Transaction = { - name: string - transactions?: Transaction[] - sender: string - receiver: string -} - -function extractSendersAndReceivers(group: Group) { - let transactionCount = 0 - let accounts: string[] = [] - - function extract(transactionArr: Transaction[]) { - if (transactionArr) { - transactionArr.forEach((transaction) => { - transactionCount++ - accounts.push(transaction.sender) - accounts.push(transaction.receiver) - if (transaction.transactions) { - extract(transaction.transactions) - } - }) - } - } - - extract(group.transactions) - - // Remove duplicates - accounts = Array.from(new Set(accounts)) - // Sort - accounts = accounts.sort((a, b) => (a > b ? 1 : a < b ? -1 : 0)) - - return { - transactionCount: transactionCount, - accounts: accounts, - } -} - -function calcArrow(transaction: Transaction, accounts: string[]): Arrow { - const fromAccount = accounts.findIndex((a) => transaction.sender === a) - const toAccount = accounts.findIndex((a) => transaction.receiver === a) - const direction = fromAccount < toAccount ? 'leftToRight' : fromAccount > toAccount ? 'rightToLeft' : 'toSelf' - - return { - from: Math.min(fromAccount, toAccount), - to: Math.max(fromAccount, toAccount), - direction: direction, - } -} - -const graphConfig = { - rowHeight: 40, - colWidth: 128, - indentationWidth: 20, - lineWidth: 2, - circleDimension: 20, -} diff --git a/src/features/transactions/pages/transaction-page.test.tsx b/src/features/transactions/pages/transaction-page.test.tsx index a670e4eeb..361110a97 100644 --- a/src/features/transactions/pages/transaction-page.test.tsx +++ b/src/features/transactions/pages/transaction-page.test.tsx @@ -17,10 +17,10 @@ import { HttpError } from '@/tests/errors' import { base64LogicsigTabLabel, tealLogicsigTabLabel, logicsigLabel } from '../components/logicsig-details' import { algod } from '@/features/common/data' import { - tableTransactionDetailsTabLabel, + transactionVisualTableTabLabel, transactionDetailsLabel, - visualTransactionDetailsTabLabel, -} from '../components/transaction-view-tabs' + transactionVisualGraphTabLabel, +} from '../components/transaction-visual-tabs' import { multisigSubsignersLabel, multisigThresholdLabel, multisigVersionLabel } from '../components/multisig-details' import { transactionBlockLabel, @@ -31,7 +31,7 @@ import { transactionTypeLabel, } from '../components/transaction-info' import { arc2NoteTabLabel, base64NoteTabLabel, jsonNoteTabLabel, noteLabel, textNoteTabLabel } from '../components/transaction-note' -import { transactionAmountLabel, transactionReceiverLabel, transactionSenderLabel } from '../components/transaction-view-table' +import { transactionAmountLabel, transactionReceiverLabel, transactionSenderLabel } from '../components/transactions-table' import { assetResultMother } from '@/tests/object-mother/asset-result' import { algoAssetResult, assetResultsAtom } from '@/features/assets/data/core' import { @@ -171,13 +171,13 @@ describe('transaction-page', () => { const viewTransactionTabList = component.getByRole('tablist', { name: transactionDetailsLabel }) expect(viewTransactionTabList).toBeTruthy() expect( - component.getByRole('tabpanel', { name: visualTransactionDetailsTabLabel }).getAttribute('data-state'), + component.getByRole('tabpanel', { name: transactionVisualGraphTabLabel }).getAttribute('data-state'), 'Visual tab should be active' ).toBe('active') // After click on the Table tab - await user.click(getByRole(viewTransactionTabList, 'tab', { name: tableTransactionDetailsTabLabel })) - const tableViewTab = component.getByRole('tabpanel', { name: tableTransactionDetailsTabLabel }) + await user.click(getByRole(viewTransactionTabList, 'tab', { name: transactionVisualTableTabLabel })) + const tableViewTab = component.getByRole('tabpanel', { name: transactionVisualTableTabLabel }) await waitFor(() => expect(tableViewTab.getAttribute('data-state'), 'Table tab should be active').toBe('active')) tableAssertion({ @@ -520,13 +520,13 @@ describe('transaction-page', () => { const viewTransactionTabList = component.getByRole('tablist', { name: transactionDetailsLabel }) expect(viewTransactionTabList).toBeTruthy() expect( - component.getByRole('tabpanel', { name: visualTransactionDetailsTabLabel }).getAttribute('data-state'), + component.getByRole('tabpanel', { name: transactionVisualGraphTabLabel }).getAttribute('data-state'), 'Visual tab should be active' ).toBe('active') // After click on the Table tab - await user.click(getByRole(viewTransactionTabList, 'tab', { name: tableTransactionDetailsTabLabel })) - const tableViewTab = component.getByRole('tabpanel', { name: tableTransactionDetailsTabLabel }) + await user.click(getByRole(viewTransactionTabList, 'tab', { name: transactionVisualTableTabLabel })) + const tableViewTab = component.getByRole('tabpanel', { name: transactionVisualTableTabLabel }) await waitFor(() => expect(tableViewTab.getAttribute('data-state'), 'Table tab should be active').toBe('active')) tableAssertion({ @@ -679,8 +679,8 @@ describe('transaction-page', () => { }) const viewTransactionTabList = component.getByRole('tablist', { name: transactionDetailsLabel }) - await user.click(getByRole(viewTransactionTabList, 'tab', { name: tableTransactionDetailsTabLabel })) - const tableViewTab = component.getByRole('tabpanel', { name: tableTransactionDetailsTabLabel }) + await user.click(getByRole(viewTransactionTabList, 'tab', { name: transactionVisualTableTabLabel })) + const tableViewTab = component.getByRole('tabpanel', { name: transactionVisualTableTabLabel }) tableAssertion({ container: tableViewTab, @@ -772,8 +772,8 @@ describe('transaction-page', () => { }) const viewTransactionTabList = component.getByRole('tablist', { name: transactionDetailsLabel }) - await user.click(getByRole(viewTransactionTabList, 'tab', { name: tableTransactionDetailsTabLabel })) - const tableViewTab = component.getByRole('tabpanel', { name: tableTransactionDetailsTabLabel }) + await user.click(getByRole(viewTransactionTabList, 'tab', { name: transactionVisualTableTabLabel })) + const tableViewTab = component.getByRole('tabpanel', { name: transactionVisualTableTabLabel }) tableAssertion({ container: tableViewTab, rows: [ @@ -925,13 +925,13 @@ describe('transaction-page', () => { const viewTransactionTabList = component.getByRole('tablist', { name: transactionDetailsLabel }) expect(viewTransactionTabList).toBeTruthy() expect( - component.getByRole('tabpanel', { name: visualTransactionDetailsTabLabel }).getAttribute('data-state'), + component.getByRole('tabpanel', { name: transactionVisualGraphTabLabel }).getAttribute('data-state'), 'Visual tab should be active' ).toBe('active') // After click on the Table tab - await user.click(getByRole(viewTransactionTabList, 'tab', { name: tableTransactionDetailsTabLabel })) - const tableViewTab = component.getByRole('tabpanel', { name: tableTransactionDetailsTabLabel }) + await user.click(getByRole(viewTransactionTabList, 'tab', { name: transactionVisualTableTabLabel })) + const tableViewTab = component.getByRole('tabpanel', { name: transactionVisualTableTabLabel }) await waitFor(() => expect(tableViewTab.getAttribute('data-state'), 'Table tab should be active').toBe('active')) tableAssertion({ @@ -1013,13 +1013,13 @@ describe('transaction-page', () => { const viewTransactionTabList = component.getByRole('tablist', { name: transactionDetailsLabel }) expect(viewTransactionTabList).toBeTruthy() expect( - component.getByRole('tabpanel', { name: visualTransactionDetailsTabLabel }).getAttribute('data-state'), + component.getByRole('tabpanel', { name: transactionVisualGraphTabLabel }).getAttribute('data-state'), 'Visual tab should be active' ).toBe('active') // After click on the Table tab - await user.click(getByRole(viewTransactionTabList, 'tab', { name: tableTransactionDetailsTabLabel })) - const tableViewTab = component.getByRole('tabpanel', { name: tableTransactionDetailsTabLabel }) + await user.click(getByRole(viewTransactionTabList, 'tab', { name: transactionVisualTableTabLabel })) + const tableViewTab = component.getByRole('tabpanel', { name: transactionVisualTableTabLabel }) await waitFor(() => expect(tableViewTab.getAttribute('data-state'), 'Table tab should be active').toBe('active')) tableAssertion({ diff --git a/src/routes/urls.ts b/src/routes/urls.ts index 099749ee2..91ca62563 100644 --- a/src/routes/urls.ts +++ b/src/routes/urls.ts @@ -22,11 +22,12 @@ export const Urls = { }), }), }), - Group: UrlTemplate`/group`.extend({ - ById: UrlTemplate`/${UrlParams.GroupId}`, - }), Block: UrlTemplate`/block`.extend({ - ById: UrlTemplate`/${UrlParams.Round}`, + ById: UrlTemplate`/${UrlParams.Round}`.extend({ + Group: UrlTemplate`/group`.extend({ + ById: UrlTemplate`/${UrlParams.GroupId}`, + }), + }), }), Account: UrlTemplate`/account`.extend({ ById: UrlTemplate`/${UrlParams.Address}`, diff --git a/src/tests/builders/group-result-builder.ts b/src/tests/builders/group-result-builder.ts new file mode 100644 index 000000000..ea8fb5e55 --- /dev/null +++ b/src/tests/builders/group-result-builder.ts @@ -0,0 +1,19 @@ +import { GroupResult } from '@/features/groups/data/types' +import { DataBuilder, dossierProxy, incrementedNumber, randomDate, randomNumberBetween, randomString } from '@makerx/ts-dossier' + +export class GroupResultBuilder extends DataBuilder { + constructor(initialState?: GroupResult) { + super( + initialState + ? initialState + : { + id: randomString(45, 45), + round: incrementedNumber('round'), + timestamp: randomDate().toISOString(), + transactionIds: Array.from({ length: randomNumberBetween(1, 1000) }, () => randomString(52, 52)), + } + ) + } +} + +export const groupResultBuilder = dossierProxy(GroupResultBuilder) diff --git a/src/tests/object-mother/group-result.ts b/src/tests/object-mother/group-result.ts new file mode 100644 index 000000000..4906abf91 --- /dev/null +++ b/src/tests/object-mother/group-result.ts @@ -0,0 +1,8 @@ +import { TransactionResult } from '@algorandfoundation/algokit-utils/types/indexer' +import { groupResultBuilder } from '../builders/group-result-builder' + +export const groupResultMother = { + groupWithTransactions: (transactions: TransactionResult[]) => { + return groupResultBuilder().withTransactionIds(transactions.map((t) => t.id)) + }, +} diff --git a/src/tests/object-mother/transaction-result.ts b/src/tests/object-mother/transaction-result.ts index a824784d0..a8571d2be 100644 --- a/src/tests/object-mother/transaction-result.ts +++ b/src/tests/object-mother/transaction-result.ts @@ -1034,4 +1034,44 @@ export const transactionResultMother = { 'tx-type': TransactionType.keyreg, }) }, + ['mainnet-7VSN7QTNBT7X4V5JH2ONKTJYF6VSQSE2H5J7VTDWFCJGSJED3QUA']: () => { + // App call + return new TransactionResultBuilder({ + 'application-transaction': { + accounts: [], + 'application-args': [], + 'application-id': 1201559522, + 'foreign-apps': [], + 'foreign-assets': [0], + 'global-state-schema': { + 'num-byte-slice': 0, + 'num-uint': 0, + }, + 'local-state-schema': { + 'num-byte-slice': 0, + 'num-uint': 0, + }, + 'on-completion': ApplicationOnComplete.noop, + }, + 'close-rewards': 0, + 'closing-amount': 0, + 'confirmed-round': 36591812, + fee: 1000, + 'first-valid': 36591810, + 'genesis-hash': 'wGHE2Pwdvd7S12BL5FaOP20EGYesN73ktiC1qzkkit8=', + 'genesis-id': 'mainnet-v1.0', + group: '/oRSr2uMFemQhwQliJO18b64Nl1QIkjA39ZszRCeSCI=', + id: '7VSN7QTNBT7X4V5JH2ONKTJYF6VSQSE2H5J7VTDWFCJGSJED3QUA', + 'intra-round-offset': 125, + 'last-valid': 36591814, + 'receiver-rewards': 0, + 'round-time': 1709251673, + sender: 'AACCDJTFPQR5UQJZ337NFR56CC44T776EWBGVJG5NY2QFTQWBWTALTEN4A', + 'sender-rewards': 0, + signature: { + sig: '0b7E1n67IzmPYtzYbrVCIN+WwAPqF1j0NrP0OQvFu10Phv77vFkrvWGtoUxZtZZZt8uqHylJA1MEln2wLrHpDQ==', + }, + 'tx-type': TransactionType.appl, + } as unknown as TransactionResult) // The type definition for App Call transaction in indexer seems to be wrong + }, }