From 1da3e2a99ec81157beadec721b2937630b545496 Mon Sep 17 00:00:00 2001 From: Hoang Dinh Date: Mon, 20 May 2024 14:40:41 +1000 Subject: [PATCH] feat: view applications * wip - showing application * wip - show application * wip - show application global state * wip - test * fix test names * more tests * test global state * wip - boxes * wip - display boxes, the names aren't showing * Show boxes table * add tests for boxes * refactor boxes a bit * view application box page * small fix * PR feedback * update text * evict application result from cache * Switch to show dialogs for boxes * PR feedback * PR feedback --- src/App.routes.tsx | 2 +- .../application-box-details-dialog.tsx | 60 ++++++ .../components/application-boxes.tsx | 29 +++ .../components/application-details.tsx | 111 +++++++++++ .../application-global-state-table.tsx | 31 +++ .../components/application-link.tsx | 22 +++ .../components/application-program.test.tsx | 37 ++++ .../components/application-program.tsx | 45 +++++ .../applications/components/labels.ts | 21 ++ .../applications/data/application-boxes.ts | 50 +++++ .../data/program-teal.ts} | 10 +- src/features/applications/mappers/index.ts | 70 ++++++- src/features/applications/models/index.ts | 41 +++- .../pages/application-page.test.tsx | 182 ++++++++++++++++++ .../applications/pages/application-page.tsx | 27 ++- src/features/assets/pages/asset-page.test.tsx | 6 +- src/features/blocks/data/latest-blocks.ts | 161 +++++++++------- src/features/blocks/pages/block-page.test.tsx | 8 +- src/features/common/components/dialog.tsx | 2 +- .../lazy-load-data-table.tsx | 4 +- src/features/groups/pages/group-page.test.tsx | 8 +- .../components/app-call-transaction-info.tsx | 7 +- .../components/logicsig-details.tsx | 53 +---- src/features/transactions/data/index.ts | 1 - .../pages/transaction-page.test.tsx | 11 +- src/tests/object-mother/application-result.ts | 36 +++- src/tests/setup/mocks.ts | 12 ++ 27 files changed, 894 insertions(+), 153 deletions(-) create mode 100644 src/features/applications/components/application-box-details-dialog.tsx create mode 100644 src/features/applications/components/application-boxes.tsx create mode 100644 src/features/applications/components/application-details.tsx create mode 100644 src/features/applications/components/application-global-state-table.tsx create mode 100644 src/features/applications/components/application-link.tsx create mode 100644 src/features/applications/components/application-program.test.tsx create mode 100644 src/features/applications/components/application-program.tsx create mode 100644 src/features/applications/components/labels.ts create mode 100644 src/features/applications/data/application-boxes.ts rename src/features/{transactions/data/logicsig-teal.ts => applications/data/program-teal.ts} (82%) create mode 100644 src/features/applications/pages/application-page.test.tsx diff --git a/src/App.routes.tsx b/src/App.routes.tsx index 925d4feb1..7fbefdd28 100644 --- a/src/App.routes.tsx +++ b/src/App.routes.tsx @@ -79,8 +79,8 @@ export const routes = evalTemplates([ }, { template: Urls.Explore.Application.ById, - element: , errorElement: , + element: , }, ], }, diff --git a/src/features/applications/components/application-box-details-dialog.tsx b/src/features/applications/components/application-box-details-dialog.tsx new file mode 100644 index 000000000..bf97bac0c --- /dev/null +++ b/src/features/applications/components/application-box-details-dialog.tsx @@ -0,0 +1,60 @@ +import { cn } from '@/features/common/utils' +import { applicationBoxNameLabel, applicationBoxValueLabel } from './labels' +import { useMemo } from 'react' +import { DescriptionList } from '@/features/common/components/description-list' +import { ApplicationBox } from '../models' +import { ApplicationId } from '../data/types' +import { RenderLoadable } from '@/features/common/components/render-loadable' +import { useLoadableApplicationBox } from '../data/application-boxes' +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from '@/features/common/components/dialog' + +type Props = { applicationId: ApplicationId; boxName: string } + +const dialogTitle = 'Application Box' +export function ApplicationBoxDetailsDialog({ applicationId, boxName }: Props) { + return ( + + + + + + +

{dialogTitle}

+
+ +
+
+ ) +} + +function InternalDialogContent({ applicationId, boxName }: Props) { + const loadableApplicationBox = useLoadableApplicationBox(applicationId, boxName) + + return ( + + {(applicationBox) => } + + ) +} + +function ApplicationBoxDetails({ applicationBox }: { applicationBox: ApplicationBox }) { + const items = useMemo( + () => [ + { + dt: applicationBoxNameLabel, + dd: applicationBox.name, + }, + { + dt: applicationBoxValueLabel, + dd: ( +
+
{applicationBox.value}
+
+ ), + }, + ], + [applicationBox.name, applicationBox.value] + ) + + return +} diff --git a/src/features/applications/components/application-boxes.tsx b/src/features/applications/components/application-boxes.tsx new file mode 100644 index 000000000..08501b292 --- /dev/null +++ b/src/features/applications/components/application-boxes.tsx @@ -0,0 +1,29 @@ +import { LazyLoadDataTable } from '@/features/common/components/lazy-load-data-table' +import { useFetchNextApplicationBoxPage } from '../data/application-boxes' +import { ApplicationId } from '../data/types' +import { ColumnDef } from '@tanstack/react-table' +import { ApplicationBoxSummary } from '../models' +import { useMemo } from 'react' +import { ApplicationBoxDetailsDialog } from './application-box-details-dialog' + +type Props = { + applicationId: ApplicationId +} + +export function ApplicationBoxes({ applicationId }: Props) { + const fetchNextPage = useFetchNextApplicationBoxPage(applicationId) + const tableColumns = useMemo(() => createTableColumns(applicationId), [applicationId]) + + return +} + +const createTableColumns = (applicationId: ApplicationId): ColumnDef[] => [ + { + header: 'Name', + accessorKey: 'name', + cell: (context) => { + const boxName = context.getValue() + return + }, + }, +] diff --git a/src/features/applications/components/application-details.tsx b/src/features/applications/components/application-details.tsx new file mode 100644 index 000000000..f95fa0e7d --- /dev/null +++ b/src/features/applications/components/application-details.tsx @@ -0,0 +1,111 @@ +import { Card, CardContent } from '@/features/common/components/card' +import { DescriptionList } from '@/features/common/components/description-list' +import { cn } from '@/features/common/utils' +import { Application } from '../models' +import { useMemo } from 'react' +import { + applicationAccountLabel, + applicationApprovalProgramLabel, + applicationApprovalProgramTabsListAriaLabel, + applicationBoxesLabel, + applicationClearStateProgramLabel, + applicationClearStateProgramTabsListAriaLabel, + applicationCreatorAccountLabel, + applicationDetailsLabel, + applicationGlobalStateByteLabel, + applicationGlobalStateLabel, + applicationGlobalStateUintLabel, + applicationIdLabel, + applicationLocalStateByteLabel, + applicationLocalStateUintLabel, +} from './labels' +import { isDefined } from '@/utils/is-defined' +import { ApplicationProgram } from './application-program' +import { ApplicationGlobalStateTable } from './application-global-state-table' +import { ApplicationBoxes } from './application-boxes' + +type Props = { + application: Application +} + +export function ApplicationDetails({ application }: Props) { + const applicationItems = useMemo( + () => [ + { + dt: applicationIdLabel, + dd: application.id, + }, + { + dt: applicationCreatorAccountLabel, + dd: application.creator, + }, + { + dt: applicationAccountLabel, + dd: application.account, + }, + application.globalStateSchema + ? { + dt: applicationGlobalStateByteLabel, + dd: application.globalStateSchema.numByteSlice, + } + : undefined, + application.localStateSchema + ? { + dt: applicationLocalStateByteLabel, + dd: application.localStateSchema.numByteSlice, + } + : undefined, + application.globalStateSchema + ? { + dt: applicationGlobalStateUintLabel, + dd: application.globalStateSchema.numUint, + } + : undefined, + application.localStateSchema + ? { + dt: applicationLocalStateUintLabel, + dd: application.localStateSchema.numUint, + } + : undefined, + ], + [application.id, application.creator, application.account, application.globalStateSchema, application.localStateSchema] + ).filter(isDefined) + + return ( +
+ + +

{applicationDetailsLabel}

+ +
+
+ + +

{applicationApprovalProgramLabel}

+ +
+
+ + +

{applicationClearStateProgramLabel}

+ +
+
+ + +

{applicationGlobalStateLabel}

+ +
+
+ + +

{applicationBoxesLabel}

+ +
+
+
+ ) +} diff --git a/src/features/applications/components/application-global-state-table.tsx b/src/features/applications/components/application-global-state-table.tsx new file mode 100644 index 000000000..b3403c4fa --- /dev/null +++ b/src/features/applications/components/application-global-state-table.tsx @@ -0,0 +1,31 @@ +import { ColumnDef } from '@tanstack/react-table' +import { Application, ApplicationGlobalStateValue } from '../models' +import { DataTable } from '@/features/common/components/data-table' +import { useMemo } from 'react' + +type Props = { + application: Application +} + +export function ApplicationGlobalStateTable({ application }: Props) { + const entries = useMemo(() => Array.from(application.globalState.entries()), [application]) + return +} + +const tableColumns: ColumnDef<[string, ApplicationGlobalStateValue]>[] = [ + { + header: 'Key', + accessorFn: (item) => item, + cell: (c) => c.getValue<[string, ApplicationGlobalStateValue]>()[0], + }, + { + header: 'Type', + accessorFn: (item) => item, + cell: (c) => c.getValue<[string, ApplicationGlobalStateValue]>()[1].type, + }, + { + header: 'Value', + accessorFn: (item) => item, + cell: (c) => c.getValue<[string, ApplicationGlobalStateValue]>()[1].value, + }, +] diff --git a/src/features/applications/components/application-link.tsx b/src/features/applications/components/application-link.tsx new file mode 100644 index 000000000..6edffe5dd --- /dev/null +++ b/src/features/applications/components/application-link.tsx @@ -0,0 +1,22 @@ +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 { ApplicationId } from '../data/types' + +type Props = PropsWithChildren<{ + applicationId: ApplicationId + className?: string +}> + +export function ApplicationLink({ applicationId, className, children }: Props) { + return ( + + {children ? children : applicationId} + + ) +} diff --git a/src/features/applications/components/application-program.test.tsx b/src/features/applications/components/application-program.test.tsx new file mode 100644 index 000000000..5aee281b3 --- /dev/null +++ b/src/features/applications/components/application-program.test.tsx @@ -0,0 +1,37 @@ +import { describe, expect, it, vi } from 'vitest' +import { getByRole, render, waitFor } from '../../../tests/testing-library' +import { ApplicationProgram, base64ProgramTabLabel, tealProgramTabLabel } from './application-program' +import { algod } from '@/features/common/data' +import { executeComponentTest } from '@/tests/test-component' + +describe('application-program', () => { + describe('when rendering an application program', () => { + const tabListName = 'test' + const program = 'CIEBQw==' + const teal = '\n#pragma version 8\nint 1\nreturn\n' + + it('should be rendered with the correct data', () => { + vi.mocked(algod.disassemble('').do).mockImplementation(() => Promise.resolve({ result: teal })) + + return executeComponentTest( + () => { + return render() + }, + async (component, user) => { + const tabList = component.getByRole('tablist', { name: tabListName }) + expect(tabList).toBeTruthy() + expect(tabList.children.length).toBe(2) + + const base64Tab = component.getByRole('tabpanel', { name: base64ProgramTabLabel }) + expect(base64Tab.getAttribute('data-state'), 'Base64 tab should be active').toBe('active') + expect(base64Tab.textContent).toBe(program) + + await user.click(getByRole(tabList, 'tab', { name: tealProgramTabLabel })) + const tealTab = component.getByRole('tabpanel', { name: tealProgramTabLabel }) + await waitFor(() => expect(tealTab.getAttribute('data-state'), 'Teal tab should be active').toBe('active')) + expect(tealTab.textContent).toBe(teal) + } + ) + }) + }) +}) diff --git a/src/features/applications/components/application-program.tsx b/src/features/applications/components/application-program.tsx new file mode 100644 index 000000000..8342a657d --- /dev/null +++ b/src/features/applications/components/application-program.tsx @@ -0,0 +1,45 @@ +import { RenderLoadable } from '@/features/common/components/render-loadable' +import { OverflowAutoTabsContent, Tabs, TabsList, TabsTrigger } from '@/features/common/components/tabs' +import { cn } from '@/features/common/utils' +import { useProgramTeal } from '../data/program-teal' + +const base64ProgramTabId = 'base64' +const tealProgramTabId = 'teal' +export const base64ProgramTabLabel = 'Base64' +export const tealProgramTabLabel = 'Teal' + +type Props = { + tabsListAriaLabel: string + base64Program: string +} +export function ApplicationProgram({ tabsListAriaLabel, base64Program }: Props) { + const [tealLoadable, fetchTeal] = useProgramTeal(base64Program) + + return ( + { + if (activeTab === tealProgramTabId) { + fetchTeal() + } + }} + > + + + {base64ProgramTabLabel} + + + {tealProgramTabLabel} + + + +
{base64Program}
+
+ +
+ {(teal) =>
{teal}
}
+
+
+
+ ) +} diff --git a/src/features/applications/components/labels.ts b/src/features/applications/components/labels.ts new file mode 100644 index 000000000..b9cffdb34 --- /dev/null +++ b/src/features/applications/components/labels.ts @@ -0,0 +1,21 @@ +export const applicationDetailsLabel = 'Application Details' +export const applicationIdLabel = 'Application ID' +export const applicationCreatorAccountLabel = 'Creator' +export const applicationAccountLabel = 'Account' +export const applicationGlobalStateByteLabel = 'Global State Byte' +export const applicationGlobalStateUintLabel = 'Global State Uint' +export const applicationLocalStateByteLabel = 'Local State Byte' +export const applicationLocalStateUintLabel = 'Local State Uint' + +export const applicationProgramsLabel = 'Application Programs' +export const applicationApprovalProgramLabel = 'Approval Program' +export const applicationClearStateProgramLabel = 'Clear State Program' +export const applicationApprovalProgramTabsListAriaLabel = 'View Application Approval Program Tabs' +export const applicationClearStateProgramTabsListAriaLabel = 'View Application Clear State Program Tabs' + +export const applicationGlobalStateLabel = 'Global State' + +export const applicationBoxesLabel = 'Boxes' + +export const applicationBoxNameLabel = 'Box Name' +export const applicationBoxValueLabel = 'Box Value' diff --git a/src/features/applications/data/application-boxes.ts b/src/features/applications/data/application-boxes.ts new file mode 100644 index 000000000..e9f429c91 --- /dev/null +++ b/src/features/applications/data/application-boxes.ts @@ -0,0 +1,50 @@ +import { ApplicationId } from './types' +import { indexer } from '@/features/common/data' +import { useMemo } from 'react' +import { atom, useAtomValue } from 'jotai' +import { ApplicationBox, ApplicationBoxSummary } from '../models' +import { Buffer } from 'buffer' +import { loadable } from 'jotai/utils' + +const fetchApplicationBoxes = async (applicationId: ApplicationId, pageSize: number, nextPageToken?: string) => { + const results = await indexer + .searchForApplicationBoxes(applicationId) + .nextToken(nextPageToken ?? '') + .limit(pageSize) + .do() + + return { + boxes: results.boxes.map((box) => ({ name: Buffer.from(box.name).toString('base64') })) satisfies ApplicationBoxSummary[], + nextPageToken: results.nextToken, + } as const +} + +const createApplicationBoxesAtom = (applicationId: ApplicationId, pageSize: number, nextPageToken?: string) => { + return atom(async () => { + const { boxes, nextPageToken: newNextPageToken } = await fetchApplicationBoxes(applicationId, pageSize, nextPageToken) + + return { + rows: boxes, + nextPageToken: newNextPageToken, + } + }) +} + +export const useFetchNextApplicationBoxPage = (applicationId: ApplicationId) => { + return useMemo(() => { + return (pageSize: number, nextPageToken?: string) => createApplicationBoxesAtom(applicationId, pageSize, nextPageToken) + }, [applicationId]) +} + +export const useApplicationBox = (applicationId: ApplicationId, boxName: string) => { + return useMemo(() => { + return atom(async () => { + const box = await indexer.lookupApplicationBoxByIDandName(applicationId, Buffer.from(boxName, 'base64')).do() + return box.get_obj_for_encoding(false) as ApplicationBox + }) + }, [applicationId, boxName]) +} + +export const useLoadableApplicationBox = (applicationId: ApplicationId, boxName: string) => { + return useAtomValue(loadable(useApplicationBox(applicationId, boxName))) +} diff --git a/src/features/transactions/data/logicsig-teal.ts b/src/features/applications/data/program-teal.ts similarity index 82% rename from src/features/transactions/data/logicsig-teal.ts rename to src/features/applications/data/program-teal.ts index cc8394703..6af9793e8 100644 --- a/src/features/transactions/data/logicsig-teal.ts +++ b/src/features/applications/data/program-teal.ts @@ -1,10 +1,10 @@ +import { algod } from '@/features/common/data' import { atom, useAtomValue, useSetAtom } from 'jotai' -import { useMemo } from 'react' import { loadable } from 'jotai/utils' +import { useMemo } from 'react' import { Buffer } from 'buffer' -import { algod } from '@/features/common/data' -export const useLogicsigTeal = (logic: string) => { +export const useProgramTeal = (base64Program: string) => { const [tealAtom, fetchTealAtom] = useMemo(() => { const tealAtom = atom | undefined>(undefined) const fetchTealAtom = atom(null, (get, set) => { @@ -12,7 +12,7 @@ export const useLogicsigTeal = (logic: string) => { return } - const program = new Uint8Array(Buffer.from(logic, 'base64')) + const program = new Uint8Array(Buffer.from(base64Program, 'base64')) set( tealAtom, algod @@ -22,7 +22,7 @@ export const useLogicsigTeal = (logic: string) => { ) }) return [tealAtom, fetchTealAtom] as const - }, [logic]) + }, [base64Program]) return [useAtomValue(loadable(tealAtom)), useSetAtom(fetchTealAtom)] as const } diff --git a/src/features/applications/mappers/index.ts b/src/features/applications/mappers/index.ts index 3d2512772..86f05c7e6 100644 --- a/src/features/applications/mappers/index.ts +++ b/src/features/applications/mappers/index.ts @@ -1,8 +1,76 @@ -import { Application } from '../models' +import { Application, ApplicationGlobalStateType, ApplicationGlobalStateValue } from '../models' import { ApplicationResult } from '@algorandfoundation/algokit-utils/types/indexer' +import { getApplicationAddress, modelsv2, encodeAddress } from 'algosdk' +import isUtf8 from 'isutf8' +import { Buffer } from 'buffer' export const asApplication = (application: ApplicationResult): Application => { return { id: application.id, + creator: application.params.creator, + account: getApplicationAddress(application.id), + globalStateSchema: application.params['global-state-schema'] + ? { + numByteSlice: application.params['global-state-schema']['num-byte-slice'], + numUint: application.params['global-state-schema']['num-uint'], + } + : undefined, + localStateSchema: application.params['local-state-schema'] + ? { + numByteSlice: application.params['local-state-schema']['num-byte-slice'], + numUint: application.params['local-state-schema']['num-uint'], + } + : undefined, + approvalProgram: application.params['approval-program'], + clearStateProgram: application.params['clear-state-program'], + globalState: asGlobalStateValue(application), + } +} + +export const asGlobalStateValue = (application: ApplicationResult): Map => { + const arr = application.params['global-state'] + .map(({ key, value }) => { + return [getKey(key), getGlobalStateValue(value)] as const + }) + .sort((a, b) => a[0].localeCompare(b[0])) + return new Map(arr) +} + +const getKey = (key: string): string => { + const buffer = Buffer.from(key, 'base64') + + if (isUtf8(buffer)) { + return buffer.toString() + } else { + return `0x${buffer.toString('hex')}` + } +} + +const getGlobalStateValue = (tealValue: modelsv2.TealValue): ApplicationGlobalStateValue => { + if (tealValue.type === 1) { + return { + type: ApplicationGlobalStateType.Bytes, + value: getValue(tealValue.bytes), + } + } + if (tealValue.type === 2) { + return { + type: ApplicationGlobalStateType.Uint, + value: tealValue.uint, + } + } + throw new Error(`Unknown type ${tealValue.type}`) +} + +const getValue = (bytes: string) => { + const buf = Buffer.from(bytes, 'base64') + if (buf.length === 32) { + return encodeAddress(new Uint8Array(buf)) + } else { + if (isUtf8(buf)) { + return buf.toString('utf8') + } else { + return buf.toString('base64') + } } } diff --git a/src/features/applications/models/index.ts b/src/features/applications/models/index.ts index c3a95f3bc..906252ffd 100644 --- a/src/features/applications/models/index.ts +++ b/src/features/applications/models/index.ts @@ -1,3 +1,42 @@ +import { ApplicationId } from '../data/types' + export type Application = { - id: number + id: ApplicationId + account: string + creator: string + globalStateSchema?: ApplicationStateSchema + localStateSchema?: ApplicationStateSchema + approvalProgram: string + clearStateProgram: string + globalState: Map + // TODO: PD - ARC2 app stuff +} + +export type ApplicationStateSchema = { + numByteSlice: number + numUint: number +} + +export type ApplicationGlobalStateValue = + | { + type: ApplicationGlobalStateType.Bytes + value: string + } + | { + type: ApplicationGlobalStateType.Uint + value: number | bigint + } + +export enum ApplicationGlobalStateType { + Bytes = 'Bytes', + Uint = 'Uint', +} + +export type ApplicationBoxSummary = { + name: string +} + +export type ApplicationBox = { + name: string + value: string } diff --git a/src/features/applications/pages/application-page.test.tsx b/src/features/applications/pages/application-page.test.tsx new file mode 100644 index 000000000..e868613f0 --- /dev/null +++ b/src/features/applications/pages/application-page.test.tsx @@ -0,0 +1,182 @@ +import { executeComponentTest } from '@/tests/test-component' +import { render, waitFor } from '@/tests/testing-library' +import { useParams } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { + ApplicationPage, + applicationFailedToLoadMessage, + applicationInvalidIdMessage, + applicationNotFoundMessage, +} from './application-page' +import { indexer } from '@/features/common/data' +import { HttpError } from '@/tests/errors' +import { applicationResultMother } from '@/tests/object-mother/application-result' +import { atom, createStore } from 'jotai' +import { applicationResultsAtom } from '../data' +import { + applicationAccountLabel, + applicationBoxesLabel, + applicationCreatorAccountLabel, + applicationDetailsLabel, + applicationGlobalStateByteLabel, + applicationGlobalStateLabel, + applicationGlobalStateUintLabel, + applicationIdLabel, + applicationLocalStateByteLabel, + applicationLocalStateUintLabel, +} from '../components/labels' +import { descriptionListAssertion } from '@/tests/assertions/description-list-assertion' +import { tableAssertion } from '@/tests/assertions/table-assertion' +import { modelsv2, indexerModels } from 'algosdk' + +describe('application-page', () => { + describe('when rendering an application using an invalid application Id', () => { + it('should display invalid application Id message', () => { + vi.mocked(useParams).mockImplementation(() => ({ applicationId: 'invalid-id' })) + + return executeComponentTest( + () => render(), + async (component) => { + await waitFor(() => expect(component.getByText(applicationInvalidIdMessage)).toBeTruthy()) + } + ) + }) + }) + + describe('when rendering an application with application Id that does not exist', () => { + it('should display not found message', () => { + vi.mocked(useParams).mockImplementation(() => ({ applicationId: '123456' })) + vi.mocked(indexer.lookupApplications(0).includeAll(true).do).mockImplementation(() => Promise.reject(new HttpError('boom', 404))) + + return executeComponentTest( + () => render(), + async (component) => { + await waitFor(() => expect(component.getByText(applicationNotFoundMessage)).toBeTruthy()) + } + ) + }) + }) + + describe('when rendering an application that failed to load', () => { + it('should display failed to load message', () => { + vi.mocked(useParams).mockImplementation(() => ({ applicationId: '123456' })) + vi.mocked(indexer.lookupApplications(0).includeAll(true).do).mockImplementation(() => Promise.reject({})) + + return executeComponentTest( + () => render(), + async (component) => { + await waitFor(() => expect(component.getByText(applicationFailedToLoadMessage)).toBeTruthy()) + } + ) + }) + }) + + describe('when rendering an application', () => { + const applicationResult = applicationResultMother['mainner-80441968']().build() + + it('should be rendered with the correct data', () => { + const myStore = createStore() + myStore.set(applicationResultsAtom, new Map([[applicationResult.id, atom(applicationResult)]])) + + vi.mocked(useParams).mockImplementation(() => ({ applicationId: applicationResult.id.toString() })) + vi.mocked(indexer.searchForApplicationBoxes(0).nextToken('').limit(10).do).mockImplementation(() => + Promise.resolve( + new indexerModels.BoxesResponse({ + applicationId: 80441968, + boxes: [ + new modelsv2.BoxDescriptor({ + name: 'AAAAAAAAAAAAAAAAABhjNpJEU5krRanhldfCDWa2Rs8=', + }), + new modelsv2.BoxDescriptor({ + name: 'AAAAAAAAAAAAAAAAAB3fFPhSWjPaBhjzsx3NbXvlBK4=', + }), + new modelsv2.BoxDescriptor({ + name: 'AAAAAAAAAAAAAAAAACctz98iaZ1MeSEbj+XCnD5CCwQ=', + }), + new modelsv2.BoxDescriptor({ + name: 'AAAAAAAAAAAAAAAAACh7tCy49kQrUL7ykRWDmayeLKk=', + }), + new modelsv2.BoxDescriptor({ + name: 'AAAAAAAAAAAAAAAAAECfyDmi7C5tEjBUI9N80BEnnAk=', + }), + new modelsv2.BoxDescriptor({ + name: 'AAAAAAAAAAAAAAAAAEKTl0iZ2Q9UxPJphTgwplTfk6U=', + }), + new modelsv2.BoxDescriptor({ + name: 'AAAAAAAAAAAAAAAAAEO4cIhnhmQ0qdQDLoXi7q0+G7o=', + }), + new modelsv2.BoxDescriptor({ + name: 'AAAAAAAAAAAAAAAAAEVLZkp/l5eUQJZ/QEYYy9yNtuc=', + }), + new modelsv2.BoxDescriptor({ + name: 'AAAAAAAAAAAAAAAAAEkbM2/K1+8IrJ/jdkgEoF/O5k0=', + }), + new modelsv2.BoxDescriptor({ + name: 'AAAAAAAAAAAAAAAAAFwILIUnvVR4R/Xe9jTEV2SzTck=', + }), + ], + nextToken: 'b64:AAAAAAAAAAAAAAAAAFwILIUnvVR4R/Xe9jTEV2SzTck=', + }) + ) + ) + + return executeComponentTest( + () => { + return render(, undefined, myStore) + }, + async (component) => { + await waitFor(async () => { + const detailsCard = component.getByLabelText(applicationDetailsLabel) + descriptionListAssertion({ + container: detailsCard, + items: [ + { term: applicationIdLabel, description: '80441968' }, + { term: applicationCreatorAccountLabel, description: '24YD4UNKUGVNGZ6QGXWIUPQ5L456FBH7LB5L6KFGQJ65YLQHXX4CQNPCZA' }, + { term: applicationAccountLabel, description: 'S3TLYVDRMR5VRKPACAYFXFLPNTYWQG37A6LPKERQ2DNABLTTGCXDUE2T3E' }, + { term: applicationGlobalStateByteLabel, description: '3' }, + { term: applicationLocalStateByteLabel, description: '0' }, + { term: applicationGlobalStateUintLabel, description: '12' }, + { term: applicationLocalStateUintLabel, description: '2' }, + ], + }) + + // Only test the first 10 rows, should be enough + const globalStateCard = component.getByLabelText(applicationGlobalStateLabel) + tableAssertion({ + container: globalStateCard, + rows: [ + { cells: ['Bids', 'Uint', '0'] }, + { cells: ['Creator', 'Bytes', '24YD4UNKUGVNGZ6QGXWIUPQ5L456FBH7LB5L6KFGQJ65YLQHXX4CQNPCZA'] }, + { cells: ['Dividend', 'Uint', '5'] }, + { cells: ['Escrow', 'Bytes', '24YD4UNKUGVNGZ6QGXWIUPQ5L456FBH7LB5L6KFGQJ65YLQHXX4CQNPCZA'] }, + { cells: ['FeesFirst', 'Uint', '250000'] }, + { cells: ['FeesSecond', 'Uint', '500000'] }, + { cells: ['Multiplier', 'Uint', '5'] }, + { cells: ['Pot', 'Uint', '0'] }, + { cells: ['Price', 'Uint', '1000000'] }, + { cells: ['RoundBegin', 'Uint', '1606905675'] }, + ], + }) + + const boxesCard = component.getByLabelText(applicationBoxesLabel) + tableAssertion({ + container: boxesCard, + rows: [ + { cells: ['AAAAAAAAAAAAAAAAABhjNpJEU5krRanhldfCDWa2Rs8='] }, + { cells: ['AAAAAAAAAAAAAAAAAB3fFPhSWjPaBhjzsx3NbXvlBK4='] }, + { cells: ['AAAAAAAAAAAAAAAAACctz98iaZ1MeSEbj+XCnD5CCwQ='] }, + { cells: ['AAAAAAAAAAAAAAAAACh7tCy49kQrUL7ykRWDmayeLKk='] }, + { cells: ['AAAAAAAAAAAAAAAAAECfyDmi7C5tEjBUI9N80BEnnAk='] }, + { cells: ['AAAAAAAAAAAAAAAAAEKTl0iZ2Q9UxPJphTgwplTfk6U='] }, + { cells: ['AAAAAAAAAAAAAAAAAEO4cIhnhmQ0qdQDLoXi7q0+G7o='] }, + { cells: ['AAAAAAAAAAAAAAAAAEVLZkp/l5eUQJZ/QEYYy9yNtuc='] }, + { cells: ['AAAAAAAAAAAAAAAAAEkbM2/K1+8IrJ/jdkgEoF/O5k0='] }, + { cells: ['AAAAAAAAAAAAAAAAAFwILIUnvVR4R/Xe9jTEV2SzTck='] }, + ], + }) + }) + } + ) + }) + }) +}) diff --git a/src/features/applications/pages/application-page.tsx b/src/features/applications/pages/application-page.tsx index 27d4063f6..89666c14d 100644 --- a/src/features/applications/pages/application-page.tsx +++ b/src/features/applications/pages/application-page.tsx @@ -3,18 +3,39 @@ import { UrlParams } from '../../../routes/urls' import { useRequiredParam } from '../../common/hooks/use-required-param' import { cn } from '@/features/common/utils' import { isInteger } from '@/utils/is-integer' +import { useLoadableApplication } from '../data' +import { RenderLoadable } from '@/features/common/components/render-loadable' +import { ApplicationDetails } from '../components/application-details' +import { is404 } from '@/utils/error' + +const transformError = (e: Error) => { + if (is404(e)) { + return new Error(applicationNotFoundMessage) + } + + // eslint-disable-next-line no-console + console.error(e) + return new Error(applicationFailedToLoadMessage) +} export const applicationPageTitle = 'Application' +export const applicationNotFoundMessage = 'Application not found' export const applicationInvalidIdMessage = 'Application Id is invalid' +export const applicationFailedToLoadMessage = 'Application failed to load' export function ApplicationPage() { - const { applicationId } = useRequiredParam(UrlParams.ApplicationId) - invariant(isInteger(applicationId), applicationInvalidIdMessage) + const { applicationId: _applicationId } = useRequiredParam(UrlParams.ApplicationId) + invariant(isInteger(_applicationId), applicationInvalidIdMessage) + + const applicationId = parseInt(_applicationId, 10) + const loadableApplication = useLoadableApplication(applicationId) return (

{applicationPageTitle}

- {applicationId} + + {(application) => } +
) } diff --git a/src/features/assets/pages/asset-page.test.tsx b/src/features/assets/pages/asset-page.test.tsx index 21b608bdd..b4698b6f5 100644 --- a/src/features/assets/pages/asset-page.test.tsx +++ b/src/features/assets/pages/asset-page.test.tsx @@ -31,7 +31,7 @@ import { ipfsGatewayUrl } from '../utils/replace-ipfs-with-gateway-if-needed' import { assetResultsAtom } from '../data' describe('asset-page', () => { - describe('when rending an asset using an invalid asset Id', () => { + describe('when rendering an asset using an invalid asset Id', () => { it('should display invalid asset Id message', () => { vi.mocked(useParams).mockImplementation(() => ({ assetId: 'invalid-id' })) @@ -44,7 +44,7 @@ describe('asset-page', () => { }) }) - describe('when rending an asset with asset Id that does not exist', () => { + describe('when rendering an asset with asset Id that does not exist', () => { it('should display not found message', () => { vi.mocked(useParams).mockImplementation(() => ({ assetId: '123456' })) vi.mocked(algod.getAssetByID(0).do).mockImplementation(() => Promise.reject(new HttpError('boom', 404))) @@ -59,7 +59,7 @@ describe('asset-page', () => { }) }) - describe('when rending an asset that failed to load', () => { + describe('when rendering an asset that failed to load', () => { it('should display failed to load message', () => { vi.mocked(useParams).mockImplementation(() => ({ assetId: '123456' })) vi.mocked(algod.getAssetByID(0).do).mockImplementation(() => Promise.reject({})) diff --git a/src/features/blocks/data/latest-blocks.ts b/src/features/blocks/data/latest-blocks.ts index f697b6413..ee55d4e86 100644 --- a/src/features/blocks/data/latest-blocks.ts +++ b/src/features/blocks/data/latest-blocks.ts @@ -20,6 +20,8 @@ import { AssetId } from '@/features/assets/data/types' import { BalanceChangeRole } from '@algorandfoundation/algokit-subscriber/types/subscription' import { accountResultsAtom } from '@/features/accounts/data' import { Address } from '@/features/accounts/data/types' +import { ApplicationId } from '@/features/applications/data/types' +import { applicationResultsAtom } from '@/features/applications/data' const maxBlocksToDisplay = 5 @@ -103,74 +105,90 @@ const subscribeToBlocksEffect = atomEffect((get, set) => { return } - const [blockTransactionIds, transactionResults, groupResults, staleAssetIds, staleAddresses] = result.subscribedTransactions.reduce( - (acc, t) => { - if (!t.parentTransactionId && t['confirmed-round'] != null) { - const round = t['confirmed-round'] - // Remove filtersMatched, balanceChanges and arc28Events, as we don't need to store them in the transaction - const { filtersMatched: _filtersMatched, balanceChanges, arc28Events: _arc28Events, ...transaction } = t - - // Accumulate transaction ids by round - acc[0].set(round, (acc[0].get(round) ?? []).concat(transaction.id)) - - // Accumulate transactions - acc[1].push(transaction) - - // Accumulate group results - if (t.group) { - const roundTime = transaction['round-time'] - const group: GroupResult = acc[2].get(t.group) ?? { - id: t.group, - round: round, - timestamp: (roundTime ? new Date(roundTime * 1000) : new Date()).toISOString(), - transactionIds: [], + const [blockTransactionIds, transactionResults, groupResults, staleAssetIds, staleAddresses, staleApplicationIds] = + result.subscribedTransactions.reduce( + (acc, t) => { + if (!t.parentTransactionId && t['confirmed-round'] != null) { + const round = t['confirmed-round'] + // Remove filtersMatched, balanceChanges and arc28Events, as we don't need to store them in the transaction + const { filtersMatched: _filtersMatched, balanceChanges, arc28Events: _arc28Events, ...transaction } = t + + // Accumulate transaction ids by round + acc[0].set(round, (acc[0].get(round) ?? []).concat(transaction.id)) + + // Accumulate transactions + acc[1].push(transaction) + + // Accumulate group results + if (t.group) { + const roundTime = transaction['round-time'] + const group: GroupResult = acc[2].get(t.group) ?? { + id: t.group, + round: round, + timestamp: (roundTime ? new Date(roundTime * 1000) : new Date()).toISOString(), + transactionIds: [], + } + group.transactionIds.push(t.id) + acc[2].set(t.group, group) } - group.transactionIds.push(t.id) - acc[2].set(t.group, group) - } - // Accumulate stale asset ids - const staleAssetIds = flattenTransactionResult(t) - .filter((t) => t['tx-type'] === algosdk.TransactionType.acfg) - .map((t) => t['asset-config-transaction']!['asset-id']) - .filter(distinct((x) => x)) - .filter(isDefined) // We ignore asset create transactions because they aren't in the atom - acc[3].push(...staleAssetIds) - - // Accumulate stale addresses - const addressesStaleDueToBalanceChanges = - balanceChanges - ?.filter((bc) => { - const isAssetOptIn = - bc.amount === 0n && - bc.assetId !== 0 && - bc.roles.includes(BalanceChangeRole.Sender) && - bc.roles.includes(BalanceChangeRole.Receiver) - const isNonZeroAmount = bc.amount !== 0n // Can either be negative (decreased balance) or positive (increased balance) - return isAssetOptIn || isNonZeroAmount + // Accumulate stale asset ids + const staleAssetIds = flattenTransactionResult(t) + .filter((t) => t['tx-type'] === algosdk.TransactionType.acfg) + .map((t) => t['asset-config-transaction']!['asset-id']) + .filter(distinct((x) => x)) + .filter(isDefined) // We ignore asset create transactions because they aren't in the atom + acc[3].push(...staleAssetIds) + + // Accumulate stale addresses + const addressesStaleDueToBalanceChanges = + balanceChanges + ?.filter((bc) => { + const isAssetOptIn = + bc.amount === 0n && + bc.assetId !== 0 && + bc.roles.includes(BalanceChangeRole.Sender) && + bc.roles.includes(BalanceChangeRole.Receiver) + const isNonZeroAmount = bc.amount !== 0n // Can either be negative (decreased balance) or positive (increased balance) + return isAssetOptIn || isNonZeroAmount + }) + .map((bc) => bc.address) + .filter(distinct((x) => x)) ?? [] + const addressesStaleDueToAppChanges = flattenTransactionResult(t) + .filter((t) => { + if (t['tx-type'] !== algosdk.TransactionType.appl) { + return false + } + const appCallTransaction = t['application-transaction']! + const isAppCreate = + appCallTransaction['on-completion'] === ApplicationOnComplete.noop && !appCallTransaction['application-id'] + const isAppOptIn = + appCallTransaction['on-completion'] === ApplicationOnComplete.optin && appCallTransaction['application-id'] + return isAppCreate || isAppOptIn }) - .map((bc) => bc.address) - .filter(distinct((x) => x)) ?? [] - const addressesStaleDueToAppChanges = flattenTransactionResult(t) - .filter((t) => { - if (t['tx-type'] !== algosdk.TransactionType.appl) { - return false - } - const appCallTransaction = t['application-transaction']! - const isAppCreate = - appCallTransaction['on-completion'] === ApplicationOnComplete.noop && !appCallTransaction['application-id'] - const isAppOptIn = appCallTransaction['on-completion'] === ApplicationOnComplete.optin && appCallTransaction['application-id'] - return isAppCreate || isAppOptIn - }) - .map((t) => t.sender) - .filter(distinct((x) => x)) - const staleAddresses = Array.from(new Set(addressesStaleDueToBalanceChanges.concat(addressesStaleDueToAppChanges))) - acc[4].push(...staleAddresses) - } - return acc - }, - [new Map(), [], new Map(), [], []] as [Map, TransactionResult[], Map, AssetId[], Address[]] - ) + .map((t) => t.sender) + .filter(distinct((x) => x)) + const staleAddresses = Array.from(new Set(addressesStaleDueToBalanceChanges.concat(addressesStaleDueToAppChanges))) + acc[4].push(...staleAddresses) + + const staleApplicationIds = flattenTransactionResult(t) + .filter((t) => t['tx-type'] === algosdk.TransactionType.appl) + .map((t) => t['application-transaction']?.['application-id']) + .filter(distinct((x) => x)) + .filter(isDefined) // We ignore application create transactions because they aren't in the atom + acc[5].push(...staleApplicationIds) + } + return acc + }, + [new Map(), [], new Map(), [], [], []] as [ + Map, + TransactionResult[], + Map, + AssetId[], + Address[], + ApplicationId[], + ] + ) const blockResults = result.blockMetadata.map((b) => { return { @@ -214,6 +232,19 @@ const subscribeToBlocksEffect = atomEffect((get, set) => { }) } + if (staleApplicationIds.length > 0) { + const currentApplicationResults = get.peek(applicationResultsAtom) + const applicationIdsToRemove = staleApplicationIds.filter((staleApplicationId) => currentApplicationResults.has(staleApplicationId)) + + set(applicationResultsAtom, (prev) => { + const next = new Map(prev) + applicationIdsToRemove.forEach((applicationId) => { + next.delete(applicationId) + }) + return next + }) + } + set(addStateExtractedFromBlocksAtom, blockResults, transactionResults, Array.from(groupResults.values())) set(liveTransactionIdsAtom, (prev) => { diff --git a/src/features/blocks/pages/block-page.test.tsx b/src/features/blocks/pages/block-page.test.tsx index 4ab9b23da..0d21496c2 100644 --- a/src/features/blocks/pages/block-page.test.tsx +++ b/src/features/blocks/pages/block-page.test.tsx @@ -19,7 +19,7 @@ import { descriptionListAssertion } from '@/tests/assertions/description-list-as import { assetResultsAtom } from '@/features/assets/data' describe('block-page', () => { - describe('when rending a block using an invalid round number', () => { + describe('when rendering a block using an invalid round number', () => { it('should display invalid round message', () => { vi.mocked(useParams).mockImplementation(() => ({ round: 'invalid-id' })) @@ -32,7 +32,7 @@ describe('block-page', () => { }) }) - describe('when rending a block that does not exist', () => { + describe('when rendering a block that does not exist', () => { it('should display not found message', () => { vi.mocked(useParams).mockImplementation(() => ({ round: '123456' })) vi.mocked(indexer.lookupBlock(0).do).mockImplementation(() => Promise.reject(new HttpError('boom', 404))) @@ -46,7 +46,7 @@ describe('block-page', () => { }) }) - describe('when rending a block that fails to load', () => { + describe('when rendering a block that fails to load', () => { it('should display failed to load message', () => { vi.mocked(useParams).mockImplementation(() => ({ round: '123456' })) vi.mocked(indexer.lookupBlock(0).do).mockImplementation(() => Promise.reject({})) @@ -60,7 +60,7 @@ describe('block-page', () => { }) }) - describe('when rending a block that exists', () => { + describe('when rendering a block that exists', () => { describe('and has no transactions', () => { const block = blockResultMother.blockWithoutTransactions().withRound(12345).withTimestamp('2024-02-29T06:52:01Z').build() diff --git a/src/features/common/components/dialog.tsx b/src/features/common/components/dialog.tsx index e6790fd0a..e013605c2 100644 --- a/src/features/common/components/dialog.tsx +++ b/src/features/common/components/dialog.tsx @@ -35,7 +35,7 @@ const DialogContent = React.forwardRef< ({ columns, fetchNextPage }: Pro )} {loadablePage.state === 'hasData' && - table.getRowModel().rows?.length && + table.getRowModel().rows.length > 0 && table.getRowModel().rows.map((row) => ( {row.getVisibleCells().map((cell) => ( @@ -87,7 +87,7 @@ export function LazyLoadDataTable({ columns, fetchNextPage }: Pro ))} ))} - {loadablePage.state === 'hasData' && table.getRowModel().rows?.length === 0 && ( + {loadablePage.state === 'hasData' && table.getRowModel().rows.length === 0 && ( No results. diff --git a/src/features/groups/pages/group-page.test.tsx b/src/features/groups/pages/group-page.test.tsx index 1d351b74a..89acbe522 100644 --- a/src/features/groups/pages/group-page.test.tsx +++ b/src/features/groups/pages/group-page.test.tsx @@ -19,7 +19,7 @@ import { tableAssertion } from '@/tests/assertions/table-assertion' import { assetResultsAtom } from '@/features/assets/data' describe('block-page', () => { - describe('when rending a group using an invalid round number', () => { + describe('when rendering a group using an invalid round number', () => { it('should display invalid round message', () => { vi.mocked(useParams).mockImplementation(() => ({ round: 'invalid-id', groupId: 'some-id' })) @@ -32,7 +32,7 @@ describe('block-page', () => { }) }) - describe('when rending a group with a round number that does not exist', () => { + describe('when rendering 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))) @@ -46,7 +46,7 @@ describe('block-page', () => { }) }) - describe('when rending a group within a block that was failed to load', () => { + describe('when rendering a group within a block that was failed to load', () => { 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({})) @@ -60,7 +60,7 @@ describe('block-page', () => { }) }) - describe('when rending a group', () => { + describe('when rendering a group', () => { const transactionResult1 = transactionResultMother['mainnet-INDQXWQXHF22SO45EZY7V6FFNI6WUD5FHRVDV6NCU6HD424BJGGA']().build() const transactionResult2 = transactionResultMother['mainnet-7VSN7QTNBT7X4V5JH2ONKTJYF6VSQSE2H5J7VTDWFCJGSJED3QUA']().build() const transactionResults = [transactionResult1, transactionResult2] diff --git a/src/features/transactions/components/app-call-transaction-info.tsx b/src/features/transactions/components/app-call-transaction-info.tsx index 222c1bdd4..d820d5b8a 100644 --- a/src/features/transactions/components/app-call-transaction-info.tsx +++ b/src/features/transactions/components/app-call-transaction-info.tsx @@ -8,6 +8,7 @@ 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' +import { ApplicationLink } from '@/features/applications/components/application-link' type Props = { transaction: AppCallTransaction | InnerAppCallTransaction @@ -41,11 +42,7 @@ export function AppCallTransactionInfo({ transaction }: Props) { }, { dt: applicationIdLabel, - dd: ( - - {transaction.applicationId} - - ), + dd: , }, { dt: actionLabel, diff --git a/src/features/transactions/components/logicsig-details.tsx b/src/features/transactions/components/logicsig-details.tsx index 3d4b4ee98..f29f7e101 100644 --- a/src/features/transactions/components/logicsig-details.tsx +++ b/src/features/transactions/components/logicsig-details.tsx @@ -1,61 +1,12 @@ import { Logicsig } from '../models' -import { Card, CardContent } from '@/features/common/components/card' -import { cn } from '@/features/common/utils' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@radix-ui/react-tabs' -import { useLogicsigTeal } from '../data' -import { RenderLoadable } from '@/features/common/components/render-loadable' +import { ApplicationProgram } from '@/features/applications/components/application-program' type LogicsigProps = { signature: Logicsig } -const base64LogicsigTabId = 'base64' -const tealLogicsigTabId = 'teal' export const logicsigLabel = 'View Logic Signature Details' -export const base64LogicsigTabLabel = 'Base64' -export const tealLogicsigTabLabel = 'Teal' export function LogicsigDetails({ signature }: LogicsigProps) { - const [tealLoadable, fetchTeal] = useLogicsigTeal(signature.logic) - - return ( - <> -

Logic Signature

- { - if (activeTab === tealLogicsigTabId) { - fetchTeal() - } - }} - > - - - {base64LogicsigTabLabel} - - - {tealLogicsigTabLabel} - - - - - -
-
{signature.logic}
-
-
-
-
- - - -
- {(teal) =>
{teal}
}
-
-
-
-
-
- - ) + return } diff --git a/src/features/transactions/data/index.ts b/src/features/transactions/data/index.ts index 630d34da4..7c9268ce2 100644 --- a/src/features/transactions/data/index.ts +++ b/src/features/transactions/data/index.ts @@ -2,5 +2,4 @@ export * from './transaction-result' export * from './transaction' export * from './latest-transactions' export * from './inner-transaction' -export * from './logicsig-teal' export * from './live-transaction-ids' diff --git a/src/features/transactions/pages/transaction-page.test.tsx b/src/features/transactions/pages/transaction-page.test.tsx index c01264514..4c9b8c168 100644 --- a/src/features/transactions/pages/transaction-page.test.tsx +++ b/src/features/transactions/pages/transaction-page.test.tsx @@ -14,7 +14,7 @@ import { atom, createStore } from 'jotai' import { transactionResultsAtom } from '../data' import { lookupTransactionById } from '@algorandfoundation/algokit-utils' import { HttpError } from '@/tests/errors' -import { base64LogicsigTabLabel, tealLogicsigTabLabel, logicsigLabel } from '../components/logicsig-details' +import { logicsigLabel } from '../components/logicsig-details' import { algod } from '@/features/common/data' import { transactionVisualTableTabLabel, @@ -77,6 +77,7 @@ import { voteParticipationKeyLabel, } from '../components/key-reg-transaction-info' import { assetResultsAtom } from '@/features/assets/data' +import { base64ProgramTabLabel, tealProgramTabLabel } from '@/features/applications/components/application-program' describe('transaction-page', () => { describe('when rendering a transaction with an invalid id', () => { @@ -255,7 +256,7 @@ describe('transaction-page', () => { expect(logicsigTabList.children.length).toBe(2) }) - const base64Tab = component.getByRole('tabpanel', { name: base64LogicsigTabLabel }) + const base64Tab = component.getByRole('tabpanel', { name: base64ProgramTabLabel }) expect(base64Tab.getAttribute('data-state'), 'Base64 tab should be active').toBe('active') expect(base64Tab.textContent).toBe(transaction.signature!.logicsig!.logic) } @@ -277,9 +278,9 @@ describe('transaction-page', () => { await waitFor(async () => { const logicsigTabList = component.getByRole('tablist', { name: logicsigLabel }) expect(logicsigTabList).toBeTruthy() - await user.click(getByRole(logicsigTabList, 'tab', { name: tealLogicsigTabLabel })) + await user.click(getByRole(logicsigTabList, 'tab', { name: tealProgramTabLabel })) }) - const tealTab = component.getByRole('tabpanel', { name: tealLogicsigTabLabel }) + const tealTab = component.getByRole('tabpanel', { name: tealProgramTabLabel }) await waitFor(() => expect(tealTab.getAttribute('data-state'), 'Teal tab should be active').toBe('active')) expect(tealTab.textContent).toBe(teal) } @@ -287,7 +288,7 @@ describe('transaction-page', () => { }) }) - describe('when rending a transaction with a note', () => { + describe('when rendering a transaction with a note', () => { const transactionBuilder = transactionResultMother.payment() describe('and the note is text', () => { diff --git a/src/tests/object-mother/application-result.ts b/src/tests/object-mother/application-result.ts index 9d308f70a..aa29ce629 100644 --- a/src/tests/object-mother/application-result.ts +++ b/src/tests/object-mother/application-result.ts @@ -1,7 +1,41 @@ -import { applicationResultBuilder } from '../builders/application-result-builder' +import { ApplicationResultBuilder, applicationResultBuilder } from '../builders/application-result-builder' +import { modelsv2 } from 'algosdk' export const applicationResultMother = { basic: () => { return applicationResultBuilder() }, + 'mainner-80441968': () => { + return new ApplicationResultBuilder({ + id: 80441968, + params: { + 'approval-program': + 'AiAIAAUEAgEKkE5kJhMDYmlkBWRyYWluB0NyZWF0b3IGRXNjcm93Bldpbm5lcgNQb3QEQmlkcwpSb3VuZEJlZ2luCFJvdW5kRW5kBVByaWNlCk11bHRpcGxpZXIFVGltZXIKVGltZXJGaXJzdAtUaW1lclNlY29uZAlGZWVzRmlyc3QKRmVlc1NlY29uZAhEaXZpZGVuZANCaWQERmVlczEYIhJAAC4xGSMSQACTMRkkEkAApDEZJRJAALoxGSEEEkAAtyg2GgASQADQKTYaABJAAbIAMRshBRJAAAEAKjEAZys2GgBnJwQxAGcnBSJnJwYiZycHMgdnJwgyBzYaARcIZycJNhoCF2cnCjYaAxdnJws2GgQXZycMNhoFF2cnDTYaBhdnJw42GgcXZycPNhoIF2cnEDYaCRdnIQRDQgGBMQAqZBJAAAEAJwVkIhJAAAEAIQRDQgFpMQAqZBJAAAEAMRshBBJAAAEAKzYaAGchBENCAUwiQ0IBRzIHJwdkDzIHJwhkDhBAAAEAIicRImYiJxIiZiEEQ0IBJjIEJRIzAQgnCWQSMwEIJwlkJw5kCBIRMwEIJwlkJw9kCBIREDMAACpkEhAzAQcrZBIQMwEQIQQSEEAAAQAnBScFZCcJZAgnBWQnCWQIJxBkCyEGCglnJwQzAQBnJwknCWQnCWQnCmQLIQcKCGchBCcRIQQnEWInCWQIZjMBCCcJZBJBAAonCCcIZCcLZAhnMwEIJwlkJw5kCBJBABghBCcSIQQnEmInDmQIZicIJwhkJwxkCGczAQgnCWQnD2QIEkEAGCEEJxIhBCcSYicPZAhmJwgnCGQnDWQIZycGJwZkIQQIZyEEQ0IAPDIEJRJAAAEAMwAAKmQSQAABADMBACtkEkAAAQAzAQgiEkAAAQAzAQcyAxJAAAEAMwEJKmQSQAABACEEQw==', + 'clear-state-program': 'AiABASJD', + creator: '24YD4UNKUGVNGZ6QGXWIUPQ5L456FBH7LB5L6KFGQJ65YLQHXX4CQNPCZA', + 'global-state': [ + toTealKeyValue({ key: 'Qmlkcw==', value: { bytes: '', type: 2, uint: 0 } }), + toTealKeyValue({ key: 'VGltZXI=', value: { bytes: '', type: 2, uint: 20 } }), + toTealKeyValue({ key: 'RGl2aWRlbmQ=', value: { bytes: '', type: 2, uint: 5 } }), + toTealKeyValue({ key: 'RmVlc0ZpcnN0', value: { bytes: '', type: 2, uint: 250000 } }), + toTealKeyValue({ key: 'TXVsdGlwbGllcg==', value: { bytes: '', type: 2, uint: 5 } }), + toTealKeyValue({ key: 'RXNjcm93', value: { bytes: '1zA+UaqhqtNn0DXsij4dXzvihP9Yer8opoJ93C4Hvfg=', type: 1, uint: 0 } }), + toTealKeyValue({ key: 'UG90', value: { bytes: '', type: 2, uint: 0 } }), + toTealKeyValue({ key: 'RmVlc1NlY29uZA==', value: { bytes: '', type: 2, uint: 500000 } }), + toTealKeyValue({ key: 'Q3JlYXRvcg==', value: { bytes: '1zA+UaqhqtNn0DXsij4dXzvihP9Yer8opoJ93C4Hvfg=', type: 1, uint: 0 } }), + toTealKeyValue({ key: 'VGltZXJGaXJzdA==', value: { bytes: '', type: 2, uint: 10 } }), + toTealKeyValue({ key: 'Um91bmRCZWdpbg==', value: { bytes: '', type: 2, uint: 1606905675 } }), + toTealKeyValue({ key: 'V2lubmVy', value: { bytes: '1zA+UaqhqtNn0DXsij4dXzvihP9Yer8opoJ93C4Hvfg=', type: 1, uint: 0 } }), + toTealKeyValue({ key: 'VGltZXJTZWNvbmQ=', value: { bytes: '', type: 2, uint: 5 } }), + toTealKeyValue({ key: 'UHJpY2U=', value: { bytes: '', type: 2, uint: 1000000 } }), + toTealKeyValue({ key: 'Um91bmRFbmQ=', value: { bytes: '', type: 2, uint: 1607905675 } }), + ], + 'global-state-schema': { 'num-byte-slice': 3, 'num-uint': 12 }, + 'local-state-schema': { 'num-byte-slice': 0, 'num-uint': 2 }, + }, + }) + }, } + +const toTealKeyValue = ({ key, value }: { key: string; value: { type: number; uint: number; bytes: string } }) => + new modelsv2.TealKeyValue({ key, value: new modelsv2.TealValue(value) }) diff --git a/src/tests/setup/mocks.ts b/src/tests/setup/mocks.ts index dd4b82057..e688c4036 100644 --- a/src/tests/setup/mocks.ts +++ b/src/tests/setup/mocks.ts @@ -54,6 +54,18 @@ vi.mock('@/features/common/data', async () => { }), }), }), + lookupApplications: vi.fn().mockReturnValue({ + includeAll: vi.fn().mockReturnValue({ + do: vi.fn().mockReturnValue({ then: vi.fn() }), + }), + }), + searchForApplicationBoxes: vi.fn().mockReturnValue({ + nextToken: vi.fn().mockReturnValue({ + limit: vi.fn().mockReturnValue({ + do: vi.fn().mockReturnValue({ then: vi.fn() }), + }), + }), + }), }, } })