Skip to content

Commit

Permalink
support multi-NFT transfer in action
Browse files Browse the repository at this point in the history
  • Loading branch information
NoahSaso committed Nov 13, 2024
1 parent c5640c3 commit 53a3c6c
Show file tree
Hide file tree
Showing 5 changed files with 261 additions and 147 deletions.
14 changes: 8 additions & 6 deletions packages/i18n/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@
"selectAllNfts": "Select all {{count}} NFTs",
"selectChain": "Select chain",
"selectNft": "Select NFT",
"selectNfts": "Select NFT(s)",
"selectToken": "Select token",
"selectValidator": "Select validator",
"selectWidget": "Select widget",
Expand Down Expand Up @@ -554,6 +555,7 @@
"relayerNotSetUp": "Relayer not set up.",
"relayerWalletNeedsFunds": "The relayer wallet needs more funds to pay fees. Press Retry to top up the wallet and try again.",
"selectAChainToContinue": "Select a chain to continue.",
"selectedNftsMustBeFromSameChain": "All selected NFTs must be from the same chain.",
"simulationFailedInvalidProposalActions": "Simulation failed. Verify your proposal actions are valid.",
"stakeInsufficient": "The DAO has {{amount}} ${{tokenSymbol}} staked, which is insufficient.",
"stargazeDaoNoCrossChainAccountsForPress_action": "This Stargaze DAO has no cross-chain accounts, and Press does not work on Stargaze. Create a cross-chain account for the DAO before setting up Press.",
Expand Down Expand Up @@ -889,7 +891,7 @@
"whoCanUseContract": "Who can use this contract?",
"whoCanVetoProposals": "Who can veto proposals?",
"whoIsCounterparty": "Who is the counterparty?",
"whoTransferNftQuestion": "Where would you like to transfer the NFT?",
"whoTransferNftsQuestion": "Where would you like to transfer the NFT(s)?",
"widget": "Widget",
"withdrawAddress": "Withdraw address"
},
Expand Down Expand Up @@ -1486,9 +1488,9 @@
"totalStakedTooltip": "The amount of ${{tokenSymbol}} currently staked with the DAO and thus earning voting power.",
"totalSupplyTooltip": "The amount of ${{tokenSymbol}} in existence.",
"transactionBuilderDescription": "Build transactions with the various actions to execute from your wallet.",
"transferNftDescription_dao": "Transfer an NFT out of the DAO's treasury.",
"transferNftDescription_gov": "Transfer an NFT from the Community Pool.",
"transferNftDescription_wallet": "Transfer an NFT from your wallet.",
"transferNftsDescription_dao": "Transfer NFT(s) out of the DAO's treasury.",
"transferNftsDescription_gov": "Transfer NFT(s) from the Community Pool.",
"transferNftsDescription_wallet": "Transfer NFT(s) from your wallet.",
"treasuryBalanceDescription": "{{numberOfTokensMinted, number}} tokens will be minted. {{memberPercent}} will be sent to members according to the distribution below. The remaining {{treasuryPercent}} will go to the DAO's treasury, where they can be distributed later via governance proposals.",
"treasuryHistoryTooltip": "The graph below displays the historical value of the treasury across all chains.",
"treasuryPercent": "Treasury percent",
Expand Down Expand Up @@ -2022,7 +2024,7 @@
"scanQrCode": "Scan QR Code",
"search": "Search",
"selectNftToBurn": "Select NFT To Burn",
"selectNftToTransfer": "Select NFT To Transfer",
"selectNftsToTransfer": "Select NFT(s) To Transfer",
"send": "Send",
"setAdminToParent": "Set admin to {{parent}}",
"setItem": "Set Item",
Expand Down Expand Up @@ -2078,7 +2080,7 @@
"transaction": "Transaction",
"transactionBuilder": "Transaction Builder",
"transfer": "Transfer",
"transferNft": "Transfer NFT",
"transferNfts": "Transfer NFT(s)",
"treasury": "Treasury",
"treasuryHistory": "Treasury History",
"treasuryValue": "Treasury Value",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,10 @@ Default.args = {
isCreating: true,
errors: {},
options: {
nftInfo: {
nftInfos: {
loading: false,
errored: false,
data: selected,
data: [selected],
},
options: {
loading: false,
Expand Down
151 changes: 108 additions & 43 deletions packages/stateful/actions/core/actions/TransferNft/Component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Check, Close } from '@mui/icons-material'
import clsx from 'clsx'
import { ComponentType, useEffect, useState } from 'react'
import { useFormContext } from 'react-hook-form'
import toast from 'react-hot-toast'
import { useTranslation } from 'react-i18next'

import {
Expand Down Expand Up @@ -33,21 +34,22 @@ import {

export type TransferNftData = {
chainId: string
collection: string
tokenId: string
nfts: {
collection: string
tokenId: string
}[]
recipient: string

// When true, uses `send` instead of `transfer_nft` to transfer the NFT.
// When true, uses `send` instead of `transfer_nft` to transfer the NFT(s).
executeSmartContract: boolean
smartContractMsg: string
}

export interface TransferNftOptions {
// The set of NFTs that may be transfered as part of this action.
options: LoadingDataWithError<LazyNftCardInfo[]>
// Information about the NFT currently selected. If undefined, no NFT is
// Information about the NFTs currently selected. If undefined, no NFTs are
// selected.
nftInfo: LoadingDataWithError<NftCardInfo> | undefined
nftInfos: LoadingDataWithError<NftCardInfo[]> | undefined

AddressInput: ComponentType<AddressInputProps<TransferNftData>>
NftSelectionModal: ComponentType<NftSelectionModalProps>
Expand All @@ -57,49 +59,73 @@ export const TransferNftComponent: ActionComponent<TransferNftOptions> = ({
fieldNamePrefix,
isCreating,
errors,
options: { options, nftInfo, AddressInput, NftSelectionModal },
options: { options, nftInfos, AddressInput, NftSelectionModal },
}) => {
const { t } = useTranslation()
const { control, watch, setValue, setError, register, clearErrors } =
useFormContext<TransferNftData>()
const {
control,
watch,
setValue,
getValues,
setError,
register,
clearErrors,
} = useFormContext<TransferNftData>()

const chainId = watch((fieldNamePrefix + 'chainId') as 'chainId')
const chain = getChainForChainId(chainId)

const tokenId = watch((fieldNamePrefix + 'tokenId') as 'tokenId')
const collection = watch((fieldNamePrefix + 'collection') as 'collection')
const nfts = watch((fieldNamePrefix + 'nfts') as 'nfts')
const executeSmartContract = watch(
(fieldNamePrefix + 'executeSmartContract') as 'executeSmartContract'
)

const selectedKey = getNftKey(chainId, collection, tokenId)

useEffect(() => {
if (!selectedKey) {
setError((fieldNamePrefix + 'collection') as 'collection', {
if (!nfts.length) {
setError((fieldNamePrefix + 'nfts') as 'nfts', {
type: 'required',
message: t('error.noNftSelected'),
})
} else {
clearErrors((fieldNamePrefix + 'collection') as 'collection')
clearErrors((fieldNamePrefix + 'nfts') as 'nfts')
}
}, [selectedKey, setError, clearErrors, t, fieldNamePrefix])
}, [nfts.length, setError, clearErrors, t, fieldNamePrefix])

// Show modal initially if creating and no NFT already selected.
const [showModal, setShowModal] = useState<boolean>(
isCreating && !selectedKey
isCreating && !nfts.length
)

// If any NFT is selected, only show NFTs from that chain. Otherwise, show all
// NFTs.
const nftOptions: LoadingDataWithError<LazyNftCardInfo[]> =
options.loading || options.errored || nfts.length === 0
? options
: {
loading: false,
errored: false,
updating: options.updating,
data: options.data.filter((o) => o.chainId === chainId),
}

const selectedKeys = nfts.map((nft) =>
getNftKey(chainId, nft.collection, nft.tokenId)
)

return (
<>
<div className="flex flex-col gap-y-4 gap-x-12 lg:flex-row lg:flex-wrap">
<div className="flex grow flex-col gap-4">
<div className="flex flex-col gap-1">
<p className="primary-text mb-3">
{isCreating
? t('form.whoTransferNftQuestion')
: t('form.recipient')}
</p>
<InputLabel
className={clsx(isCreating && 'mb-2')}
name={
isCreating
? t('form.whoTransferNftsQuestion')
: t('form.recipient')
}
primary={isCreating}
/>

<ChainProvider chainId={chainId}>
<AddressInput
Expand Down Expand Up @@ -185,31 +211,42 @@ export const TransferNftComponent: ActionComponent<TransferNftOptions> = ({
</div>

<div className="flex grow flex-col gap-2">
{nftInfo &&
(nftInfo.loading ? (
{!isCreating && (
<InputLabel
name={t('title.numNfts', {
count: nfts.length,
})}
/>
)}

{nftInfos &&
(nftInfos.loading ? (
<HorizontalNftCardLoader />
) : nftInfo.errored ? (
<ErrorPage error={nftInfo.error} />
) : nftInfos.errored ? (
<ErrorPage error={nftInfos.error} />
) : (
<HorizontalNftCard {...nftInfo.data} />
<div className="flex flex-col gap-1">
{nftInfos.data.map(({ key, ...nftInfo }) => (
<HorizontalNftCard key={key} {...nftInfo} />
))}
</div>
))}

{isCreating && (
<Button
className={clsx(
'text-text-tertiary',
nftInfo && !nftInfo.loading && !nftInfo.errored
nftInfos && !nftInfos.loading && !nftInfos.errored
? 'self-end'
: 'self-start'
)}
onClick={() => setShowModal(true)}
variant="secondary"
variant={nfts.length ? 'secondary' : 'primary'}
>
{t('button.selectNft')}
{t('button.selectNfts')}
</Button>
)}

<InputErrorMessage error={errors?.collection} />
<InputErrorMessage error={errors?.nfts} />
</div>
</div>

Expand All @@ -221,24 +258,52 @@ export const TransferNftComponent: ActionComponent<TransferNftOptions> = ({
onClick: () => setShowModal(false),
}}
header={{
title: t('title.selectNftToTransfer'),
title: t('title.selectNftsToTransfer'),
}}
nfts={options}
nfts={nftOptions}
onClose={() => setShowModal(false)}
onNftClick={(nft) => {
if (nft.key === selectedKey) {
setValue((fieldNamePrefix + 'tokenId') as 'tokenId', '')
setValue((fieldNamePrefix + 'collection') as 'collection', '')
} else {
const selected = getValues((fieldNamePrefix + 'nfts') as 'nfts')

// If no NFTs are selected, set the chain and selected NFT.
if (selected.length === 0) {
setValue((fieldNamePrefix + 'chainId') as 'chainId', nft.chainId)
setValue((fieldNamePrefix + 'tokenId') as 'tokenId', nft.tokenId)
setValue((fieldNamePrefix + 'nfts') as 'nfts', [
{
collection: nft.collectionAddress,
tokenId: nft.tokenId,
},
])
} else if (
// If the NFT is already selected, remove it.
selected.some(
(n) =>
n.collection === nft.collectionAddress &&
n.tokenId === nft.tokenId
)
) {
setValue(
(fieldNamePrefix + 'collection') as 'collection',
nft.collectionAddress
(fieldNamePrefix + 'nfts') as 'nfts',
nfts.filter(
(n) => getNftKey(chainId, n.collection, n.tokenId) !== nft.key
)
)
} else if (nft.chainId === chainId) {
// Otherwise, add the NFT if from the same chain.
setValue((fieldNamePrefix + 'nfts') as 'nfts', [
...selected,
{
collection: nft.collectionAddress,
tokenId: nft.tokenId,
},
])
} else {
// This should never happen since we filter NFTs based on the
// chain of the first one selected, but if it does, show an error.
toast.error(t('error.selectedNftsMustBeFromSameChain'))
}
}}
selectedKeys={selectedKey ? [selectedKey] : []}
selectedKeys={selectedKeys}
visible={showModal}
/>
)}
Expand Down
13 changes: 9 additions & 4 deletions packages/stateful/actions/core/actions/TransferNft/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# TransferNft

Send an NFT owned by the current account.
Send one or more NFTs owned by the current account.

## Bulk import format

Expand All @@ -15,10 +15,15 @@ guide](https://github.com/DA0-DA0/dao-dao-ui/wiki/Bulk-importing-actions).

```json
{
"collection": "<NFT COLLECTION ADDRESS>",
"tokenId": "<NFT TOKEN ID>",
"chainId": "<CHAIN ID>",
"nfts": [
{
"collection": "<NFT COLLECTION ADDRESS>",
"tokenId": "<NFT TOKEN ID>"
},
...
],
"recipient": "<RECIPIENT ADDRESS>",

"executeSmartContract": <true | false>,
"smartContractMsg": "<SMART CONTRACT MESSAGE>"
}
Expand Down
Loading

0 comments on commit 53a3c6c

Please sign in to comment.