Skip to content

Commit

Permalink
feat: improve UX for transaction type arguments (#271)
Browse files Browse the repository at this point in the history
  • Loading branch information
PatrickDinh authored Oct 11, 2024
1 parent a3e1647 commit 3150dea
Show file tree
Hide file tree
Showing 26 changed files with 935 additions and 457 deletions.
7 changes: 1 addition & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -190,10 +190,5 @@
"ws@>7.0.0 <7.5.9": "7.5.10",
"@algorandfoundation/algokit-utils@<7.0.0": "^7.0.0-beta.8",
"path-to-regexp@>= 0.2.0 <8.0.0": "8.0.0"
},
"madge": {
"excludeRegExp": [
"method-call-transaction-builder.tsx"
]
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,20 @@ import { ApplicationId } from '../data/types'
import { RenderLoadable } from '@/features/common/components/render-loadable'
import { useLoadableApplicationBox } from '../data/application-boxes'
import { Dialog, DialogContent, DialogHeader, DialogTrigger, MediumSizeDialogBody } from '@/features/common/components/dialog'
import { base64ToUtf8IfValid } from '@/utils/base64-to-utf8'

type Props = { applicationId: ApplicationId; boxName: string }

const dialogTitle = 'Application Box'
export function ApplicationBoxDetailsDialog({ applicationId, boxName }: Props) {
const decodedBoxName = useMemo(() => {
return base64ToUtf8IfValid(boxName)
}, [boxName])

return (
<Dialog modal={true}>
<DialogTrigger>
<label className={cn('text-primary underline cursor-pointer')}>{boxName}</label>
<label className={cn('text-primary underline cursor-pointer')}>{decodedBoxName}</label>
</DialogTrigger>
<DialogContent>
<DialogHeader>
Expand All @@ -40,23 +45,25 @@ function InternalDialogContent({ applicationId, boxName }: Props) {
}

function ApplicationBoxDetails({ applicationBox }: { applicationBox: ApplicationBox }) {
const items = useMemo(
() => [
const items = useMemo(() => {
const decodedBoxName = base64ToUtf8IfValid(applicationBox.name)
const decodedBoxValue = base64ToUtf8IfValid(applicationBox.value)

return [
{
dt: applicationBoxNameLabel,
dd: applicationBox.name,
dd: decodedBoxName,
},
{
dt: applicationBoxValueLabel,
dd: (
<div className="grid">
<div className="overflow-y-auto break-words"> {applicationBox.value}</div>
<div className="overflow-y-auto break-words"> {decodedBoxValue}</div>
</div>
),
},
],
[applicationBox.name, applicationBox.value]
)
]
}, [applicationBox.name, applicationBox.value])

return <DescriptionList items={items} />
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function Method({ method, applicationId, readonly }: MethodProps) {
transactionType: algosdk.ABITransactionType.appl,
transaction: {
applicationId: applicationId,
methodName: method.name,
method: method.abiMethod,
onComplete:
method.callConfig && method.callConfig.call.length > 0
? (method.callConfig.call[0] as algosdk.OnApplicationComplete as BuildAppCallTransactionResult['onComplete'])
Expand All @@ -83,7 +83,7 @@ function Method({ method, applicationId, readonly }: MethodProps) {
if (transaction && transaction.type === BuildableTransactionType.MethodCall) {
setTransaction(transaction)
}
}, [applicationId, method.callConfig, method.name, open])
}, [applicationId, method, open])

const handleTransactionSent = useCallback((transactions: Transaction[]) => {
const appCallTransactions = transactions.filter((txn) => txn.type === TransactionType.AppCall)
Expand Down
11 changes: 2 additions & 9 deletions src/features/applications/mappers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { Arc32AppSpec, Arc4AppSpec } from '@/features/app-interfaces/data/types'
import { isArc32AppSpec } from '@/features/common/utils'
import { CallConfigValue } from '@algorandfoundation/algokit-utils/types/app-spec'
import { Hint } from '@/features/app-interfaces/data/types/arc-32/application'
import { base64ToUtf8IfValid } from '@/utils/base64-to-utf8'

export const asApplicationSummary = (application: ApplicationResult): ApplicationSummary => {
return {
Expand Down Expand Up @@ -95,15 +96,7 @@ const getValue = (bytes: string) => {
if (buf.length === 32) {
return encodeAddress(new Uint8Array(buf))
}

if (isUtf8(buf)) {
const utf8Decoded = buf.toString('utf8')
// Check if the string contains any unprintable characters
if (!utf8Decoded.match(/[\p{Cc}\p{Cn}\p{Cs}]+/gu)) {
return utf8Decoded
}
}
return buf.toString('base64')
return base64ToUtf8IfValid(bytes)
}

const callValues: CallConfigValue[] = ['ALL', 'CALL']
Expand Down
13 changes: 7 additions & 6 deletions src/features/common/data/algo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,19 @@ const shouldCreateKmdClient = (config: NetworkConfig) => {
}

// Init the network config from local storage
const networkConfig = settingsStore.get(networkConfigAtom)
export let networkConfig = settingsStore.get(networkConfigAtom)
export let indexer = ClientManager.getIndexerClient(networkConfig.indexer)
export let algod = ClientManager.getAlgodClient(networkConfig.algod)
export let kmd: algosdk.Kmd | undefined = shouldCreateKmdClient(networkConfig) ? ClientManager.getKmdClient(networkConfig.kmd!) : undefined
export let algorandClient = AlgorandClient.fromClients({ algod, indexer, kmd })

export const updateClientConfig = (networkConfig: NetworkConfigWithId) => {
indexer = ClientManager.getIndexerClient(networkConfig.indexer)
algod = ClientManager.getAlgodClient(networkConfig.algod)
kmd = shouldCreateKmdClient(networkConfig) ? ClientManager.getKmdClient(networkConfig.kmd!) : undefined
export const updateClientConfig = (_networkConfig: NetworkConfigWithId) => {
networkConfig = _networkConfig
indexer = ClientManager.getIndexerClient(_networkConfig.indexer)
algod = ClientManager.getAlgodClient(_networkConfig.algod)
kmd = shouldCreateKmdClient(_networkConfig) ? ClientManager.getKmdClient(_networkConfig.kmd!) : undefined
algorandClient = AlgorandClient.fromClients({ algod, indexer, kmd })
if (networkConfig.id !== localnetId) {
if (_networkConfig.id !== localnetId) {
algorandClient.setDefaultValidityWindow(30)
}
}
3 changes: 3 additions & 0 deletions src/features/common/data/state-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { accountResultsAtom } from '@/features/accounts/data'
import { applicationMetadataResultsAtom } from '@/features/applications/data/application-metadata'
import { applicationResultsAtom } from '@/features/applications/data'
import { assetMetadataResultsAtom, assetResultsAtom } from '@/features/assets/data'
import { forwardNfdResultsAtom, reverseNfdsAtom } from '@/features/nfd/data/nfd-result'

const cleanUpIntervalMillis = 600_000 // 10 minutes
export const cachedDataExpirationMillis = 1_800_000 // 30 minutes
Expand All @@ -32,6 +33,8 @@ const stateCleanupEffect = atomEffect((get, set) => {
removeExpired(applicationResultsAtom)
removeExpired(assetMetadataResultsAtom)
removeExpired(assetResultsAtom)
removeExpired(forwardNfdResultsAtom)
removeExpired(reverseNfdsAtom)
}, cleanUpIntervalMillis)

return () => clearInterval(cleanup)
Expand Down
57 changes: 1 addition & 56 deletions src/features/forms/components/transaction-form-item.tsx
Original file line number Diff line number Diff line change
@@ -1,75 +1,20 @@
import algosdk from 'algosdk'
import { Path, PathValue, useFormContext } from 'react-hook-form'
import { FormItemProps } from '@/features/forms/components/form-item'
import { Button } from '@/features/common/components/button'
import { useMemo } from 'react'
import { DescriptionList } from '@/features/common/components/description-list'
import { HintText } from './hint-text'
import { useFormFieldError } from '../hooks/use-form-field-error'
import { BuildTransactionResult } from '@/features/transaction-wizard/models'
import { asDescriptionListItems } from '@/features/transaction-wizard/mappers'
import { EllipsisVertical, Plus } from 'lucide-react'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/features/common/components/dropdown-menu'

export const transactionTypeLabel = 'Transaction type'

export interface TransactionFormItemProps<TSchema extends Record<string, unknown> = Record<string, unknown>>
extends Omit<FormItemProps<TSchema>, 'children'> {
transactionType: algosdk.ABITransactionType
placeholder?: string
onEdit: () => void
}
extends Omit<FormItemProps<TSchema>, 'children'> {}

export function TransactionFormItem<TSchema extends Record<string, unknown> = Record<string, unknown>>({
transactionType,
field,
helpText,
onEdit,
}: TransactionFormItemProps<TSchema>) {
const { watch, setValue } = useFormContext<TSchema>()
const error = useFormFieldError(field)
const fieldValue = watch(field) as BuildTransactionResult | undefined

const transactionFields = useMemo(() => {
if (fieldValue) {
return asDescriptionListItems(fieldValue)
} else {
return []
}
}, [fieldValue])

return (
<>
{transactionFields.length > 0 && (
<div className="relative">
<DescriptionList items={transactionFields} dtClassName="w-24 truncate" />
<div className="absolute right-0 top-0 w-8">
<DropdownMenu>
<DropdownMenuTrigger className="flex w-full items-center justify-center py-4">
<EllipsisVertical size={16} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" side="left">
<DropdownMenuItem onClick={() => onEdit()}>Edit</DropdownMenuItem>
<DropdownMenuItem onClick={() => setValue(field, undefined as PathValue<TSchema, Path<TSchema>>)}>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
)}
{transactionFields.length === 0 && (
<div className="absolute right-0 top-6">
<Button
variant="outline-secondary"
type="button"
onClick={() => onEdit()}
disabled={transactionType === algosdk.ABITransactionType.appl}
disabledReason="App call transaction arguments are currently not supported"
icon={<Plus size={16} />}
>
Add Transaction
</Button>
</div>
)}
<div>
<HintText errorText={error?.message} helpText={helpText} />
</div>
Expand Down
2 changes: 2 additions & 0 deletions src/features/network/data/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const defaultNetworkConfigs: Record<NetworkId, NetworkConfig> = {
url: config.testNetDispenserApiUrl,
address: config.testNetDispenserAddress,
},
nfdApiUrl: 'https://api.testnet.nf.domains',
},
[mainnetId]: {
name: 'MainNet',
Expand All @@ -98,6 +99,7 @@ export const defaultNetworkConfigs: Record<NetworkId, NetworkConfig> = {
port: 443,
},
walletProviders: nonLocalnetWalletProviders,
nfdApiUrl: 'https://api.nf.domains',
},
}

Expand Down
1 change: 1 addition & 0 deletions src/features/network/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export type NetworkConfig = {
url: string
address: Address
}
nfdApiUrl?: string
}

export type NetworkConfigWithId = {
Expand Down
3 changes: 3 additions & 0 deletions src/features/nfd/data/is-nfd.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const nfdRegex = /^(.+\.algo)$/

export const isNFD = (name: string) => nfdRegex.test(name)
114 changes: 114 additions & 0 deletions src/features/nfd/data/nfd-result.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { createReadOnlyAtomAndTimestamp, readOnlyAtomCache } from '@/features/common/data'
import { Atom, atom, Getter, Setter } from 'jotai'
import { Nfd, NfdLookup, NfdResult } from './types'
import { Address } from '@/features/accounts/data/types'
import { networkConfig } from '@/features/common/data/algo-client'

const getReverseLookupNfd = async (_: Getter, set: Setter, address: Address, nfdApiUrl: string): Promise<Nfd | null> => {
try {
const response = await fetch(`${nfdApiUrl}/nfd/lookup?address=${address}`, {
method: 'GET',
headers: {
accept: 'application/json',
},
})

if (!response.ok) {
return null
}

const body = await response.json()
const nfd = body[address] as NfdResult
const nfdResult = {
name: nfd.name,
depositAccount: nfd.depositAccount,
caAlgo: nfd.caAlgo ?? [],
} satisfies NfdResult

// Cache the NFD result for forward lookups. Reverse lookups will also use this cache
set(forwardNfdResultsAtom, (prev) => {
if (!prev.has(nfdResult.name)) {
const next = new Map(prev)
next.set(nfdResult.name, createReadOnlyAtomAndTimestamp(nfdResult))
return next
}
return prev
})

// Cache all *other* addresses associated with this NFD for reverse lookups
set(reverseNfdsAtom, (prev) => {
const next = new Map(prev)
const addressesToAdd = new Set([nfdResult.depositAccount, ...nfdResult.caAlgo].filter((a) => a !== address))
addressesToAdd.forEach((address) => {
if (!prev.has(address)) {
next.set(address, createReadOnlyAtomAndTimestamp(nfdResult.name))
}
})
return next
})

return nfdResult.name
} catch (e: unknown) {
return null
}
}

const getForwardLookupNfdResult = async (_: Getter, set: Setter, nfd: Nfd, nfdApiUrl: string): Promise<NfdResult | null> => {
try {
const response = await fetch(`${nfdApiUrl}/nfd/${nfd}?view=tiny`, {
method: 'GET',
headers: {
accept: 'application/json',
},
})

if (!response.ok) {
return null
}

const body = (await response.json()) as NfdResult
const nfdResult = {
name: body.name,
depositAccount: body.depositAccount,
caAlgo: body.caAlgo ?? [],
} satisfies NfdResult

// Cache all addresses associated with this NFD for reverse lookups
set(reverseNfdsAtom, (prev) => {
const next = new Map(prev)
const addressesToAdd = new Set([nfdResult.depositAccount, ...nfdResult.caAlgo])
addressesToAdd.forEach((address) => {
if (!prev.has(address)) {
next.set(address, createReadOnlyAtomAndTimestamp(nfdResult.name))
}
})
return next
})

return nfdResult
} catch (e: unknown) {
return null
}
}

export const getNfdResultAtom = (nfdLookup: NfdLookup): Atom<Promise<NfdResult | null>> => {
return atom(async (get) => {
if (!networkConfig.nfdApiUrl) {
return null
}

if ('nfd' in nfdLookup) {
return await get(getForwardNfdResultAtom(nfdLookup.nfd, networkConfig.nfdApiUrl, { skipTimestampUpdate: true }))
}

const nfd = await get(getReverseNfdAtom(nfdLookup.address, networkConfig.nfdApiUrl, { skipTimestampUpdate: true }))
if (nfd) {
return await get(getForwardNfdResultAtom(nfd, networkConfig.nfdApiUrl, { skipTimestampUpdate: true }))
}
return null
})
}

const [forwardNfdResultsAtom, getForwardNfdResultAtom] = readOnlyAtomCache(getForwardLookupNfdResult, (nfd) => nfd)
const [reverseNfdsAtom, getReverseNfdAtom] = readOnlyAtomCache(getReverseLookupNfd, (address) => address)
export { forwardNfdResultsAtom, reverseNfdsAtom }
18 changes: 18 additions & 0 deletions src/features/nfd/data/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Address } from '@/features/accounts/data/types'

export type Nfd = string

export type ForwardNfdLookup = {
nfd: Nfd
}

export type ReverseNfdLookpup = {
address: string
}

export type NfdLookup = ReverseNfdLookpup | ForwardNfdLookup
export type NfdResult = {
name: Nfd
depositAccount: Address
caAlgo: Address[]
}
Loading

0 comments on commit 3150dea

Please sign in to comment.