From a736cce840ff81f08acb65008d3fc42963f19b22 Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 7 May 2024 16:17:39 -0400 Subject: [PATCH 1/6] feat(ui): two step signing flow to add staking pool --- ui/src/components/AddPoolModal.tsx | 139 +++++++++++++++++++++++------ 1 file changed, 111 insertions(+), 28 deletions(-) diff --git a/ui/src/components/AddPoolModal.tsx b/ui/src/components/AddPoolModal.tsx index f8797b5e..ee4f83be 100644 --- a/ui/src/components/AddPoolModal.tsx +++ b/ui/src/components/AddPoolModal.tsx @@ -1,23 +1,21 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useQuery, useQueryClient } from '@tanstack/react-query' +import { ProgressBar } from '@tremor/react' import { useWallet } from '@txnlab/use-wallet-react' import * as React from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' -import { - addStakingPool, - fetchMbrAmounts, - fetchValidator, - initStakingPoolStorage, -} from '@/api/contracts' -import { poolAssignmentQueryOptions } from '@/api/queries' +import { addStakingPool, fetchValidator, initStakingPoolStorage } from '@/api/contracts' +import { mbrQueryOptions, poolAssignmentQueryOptions } from '@/api/queries' +import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount' import { NodeSelect } from '@/components/NodeSelect' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, + DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' @@ -29,7 +27,7 @@ import { FormLabel, FormMessage, } from '@/components/ui/form' -import { NodePoolAssignmentConfig, Validator } from '@/interfaces/validator' +import { NodePoolAssignmentConfig, Validator, ValidatorPoolKey } from '@/interfaces/validator' import { findFirstAvailableNode, setValidatorQueriesData } from '@/utils/contracts' import { cn } from '@/utils/ui' @@ -49,6 +47,9 @@ export function AddPoolModal({ poolAssignment: poolAssignmentProp, }: AddPoolModalProps) { const [isSigning, setIsSigning] = React.useState(false) + const [progress, setProgress] = React.useState(0) + const [currentStep, setCurrentStep] = React.useState(1) + const [poolKey, setPoolKey] = React.useState(null) const queryClient = useQueryClient() const { transactionSigner, activeAddress } = useWallet() @@ -63,6 +64,9 @@ export function AddPoolModal({ const { isValid } = form.formState + const mbrQuery = useQuery(mbrQueryOptions) + const { poolMbr = 0, poolInitMbr = 0 } = mbrQuery.data || {} + const assignmentQuery = useQuery(poolAssignmentQueryOptions(validator?.id || '', !!validator)) const poolAssignment = assignmentQuery.data || poolAssignmentProp @@ -78,17 +82,28 @@ export function AddPoolModal({ form.setValue('nodeNum', defaultNodeNum) }, [defaultNodeNum, form.setValue]) + const handleResetForm = () => { + form.reset({ nodeNum: '1' }) + form.clearErrors() + setProgress(0) + setCurrentStep(1) + setPoolKey(null) + setIsSigning(false) + } + const handleOpenChange = (open: boolean) => { if (!open) { - setValidator(null) - form.reset({ nodeNum: '1' }) + if (validator) setValidator(null) + setTimeout(() => handleResetForm(), 500) + } else { + handleResetForm() } } const toastIdRef = React.useRef(`toast-${Date.now()}-${Math.random()}`) const TOAST_ID = toastIdRef.current - const onSubmit = async (data: z.infer) => { + const handleCreatePool = async (data: z.infer) => { const toastId = `${TOAST_ID}-add-pool` try { @@ -98,14 +113,13 @@ export function AddPoolModal({ throw new Error('No active address') } - const { poolMbr, poolInitMbr } = await queryClient.ensureQueryData({ - queryKey: ['mbr'], - queryFn: () => fetchMbrAmounts(), - }) + if (!poolMbr) { + throw new Error('No MBR data found') + } toast.loading('Sign transactions to add staking pool...', { id: toastId }) - const stakingPool = await addStakingPool( + const stakingPoolKey = await addStakingPool( validator!.id, Number(data.nodeNum), poolMbr, @@ -113,33 +127,73 @@ export function AddPoolModal({ activeAddress, ) + setPoolKey(stakingPoolKey) + + toast.success(`Staking pool ${stakingPoolKey.poolId} created!`, { + id: toastId, + duration: 5000, + }) + setProgress(50) + setCurrentStep(2) + } catch (error) { + toast.error('Failed to create staking pool', { id: toastId }) + console.error(error) + handleOpenChange(false) + } finally { + setIsSigning(false) + } + } + + const handlePayPoolMbr = async (event: React.MouseEvent) => { + event.preventDefault() + + const toastId = `${TOAST_ID}-pay-mbr` + + try { + setIsSigning(true) + + if (!activeAddress) { + throw new Error('No active address') + } + + if (!poolKey) { + throw new Error('No pool found') + } + + if (!poolInitMbr) { + throw new Error('No MBR data found') + } + + toast.loading(`Sign transaction to pay MBR for pool ${poolKey.poolId}...`, { + id: toastId, + }) + const optInRewardToken = validator?.config.rewardTokenId !== 0 && validator?.state.numPools === 0 await initStakingPoolStorage( - stakingPool.poolAppId, + poolKey.poolAppId, poolInitMbr, optInRewardToken, transactionSigner, activeAddress, ) - toast.success(`Staking pool ${stakingPool.poolId} created!`, { + toast.success(`Pool ${poolKey.poolId} MBR paid successfully!`, { id: toastId, duration: 5000, }) + setProgress(100) // Refetch validator data const newData = await fetchValidator(validator!.id) - - // Seed/update query cache with new data setValidatorQueriesData(queryClient, newData) + + setTimeout(() => handleOpenChange(false), 1000) } catch (error) { - toast.error('Failed to create staking pool', { id: toastId }) + toast.error('Failed to pay MBR', { id: toastId }) console.error(error) - } finally { setIsSigning(false) - setValidator(null) } } @@ -154,12 +208,16 @@ export function AddPoolModal({
- + ( - + Select Node {poolAssignment && !!validator && ( )} /> - + {currentStep == 2 && ( +
+ Pay Minimum Required Balance +

+ To initialize the staking pool, a{' '} + {' '} + MBR payment is required. +

+
+ )} + + {currentStep === 1 && ( + + )} + {currentStep === 2 && ( + + )} + + +
+ +
From a54db60a7e4511284c963556a38f0aa63924c56d Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 7 May 2024 16:19:05 -0400 Subject: [PATCH 2/6] feat(ui): include MBR query in root route loader --- ui/src/components/AddStakeModal.tsx | 1 - ui/src/routes/__root.tsx | 16 ++++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/ui/src/components/AddStakeModal.tsx b/ui/src/components/AddStakeModal.tsx index 6de07e05..3f84cc0b 100644 --- a/ui/src/components/AddStakeModal.tsx +++ b/ui/src/components/AddStakeModal.tsx @@ -108,7 +108,6 @@ export function AddStakeModal({ Number(validator?.config.gatingAssetMinBalance), ) - // @todo: make this a custom hook, call from higher up and pass down as prop const mbrQuery = useQuery(mbrQueryOptions) const stakerMbr = mbrQuery.data?.stakerMbr || 0 diff --git a/ui/src/routes/__root.tsx b/ui/src/routes/__root.tsx index aebb3677..62239853 100644 --- a/ui/src/routes/__root.tsx +++ b/ui/src/routes/__root.tsx @@ -2,7 +2,7 @@ import { QueryClient } from '@tanstack/react-query' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { createRootRouteWithContext, Outlet } from '@tanstack/react-router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' -import { blockTimeQueryOptions, constraintsQueryOptions } from '@/api/queries' +import { blockTimeQueryOptions, constraintsQueryOptions, mbrQueryOptions } from '@/api/queries' import { Layout } from '@/components/Layout' export const Route = createRootRouteWithContext<{ @@ -13,19 +13,27 @@ export const Route = createRootRouteWithContext<{ return { blockTimeQueryOptions, constraintsQueryOptions, + mbrQueryOptions, } }, - loader: ({ context: { queryClient, blockTimeQueryOptions, constraintsQueryOptions } }) => { + loader: ({ + context: { queryClient, blockTimeQueryOptions, constraintsQueryOptions, mbrQueryOptions }, + }) => { queryClient.ensureQueryData(blockTimeQueryOptions) queryClient.ensureQueryData(constraintsQueryOptions) + queryClient.ensureQueryData(mbrQueryOptions) }, component: () => ( <> - - + {import.meta.env.DEV && ( + <> + + + + )} ), notFoundComponent: () => { From ff11b2df71ac8fff9dfeff41c5f43ee426269ae4 Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 7 May 2024 16:20:56 -0400 Subject: [PATCH 3/6] fix(ui): show actual error message in StakingDetails This also adds a reusable ErrorAlert component that should replace any placeholder content to convey error state. --- ui/src/components/ErrorAlert.tsx | 17 +++++++++++++++++ .../ValidatorDetails/StakingDetails.tsx | 7 ++++--- ui/src/hooks/useStakersChartData.ts | 10 +++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 ui/src/components/ErrorAlert.tsx diff --git a/ui/src/components/ErrorAlert.tsx b/ui/src/components/ErrorAlert.tsx new file mode 100644 index 00000000..5f54df17 --- /dev/null +++ b/ui/src/components/ErrorAlert.tsx @@ -0,0 +1,17 @@ +import { CircleX } from 'lucide-react' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' + +interface ErrorAlertProps { + title: string + message: string +} + +export function ErrorAlert({ title, message }: ErrorAlertProps) { + return ( + + + {title} + {message} + + ) +} diff --git a/ui/src/components/ValidatorDetails/StakingDetails.tsx b/ui/src/components/ValidatorDetails/StakingDetails.tsx index 73f34488..33884425 100644 --- a/ui/src/components/ValidatorDetails/StakingDetails.tsx +++ b/ui/src/components/ValidatorDetails/StakingDetails.tsx @@ -6,6 +6,7 @@ import { Ban, Copy, Signpost } from 'lucide-react' import * as React from 'react' import { AddStakeModal } from '@/components/AddStakeModal' import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount' +import { ErrorAlert } from '@/components/ErrorAlert' import { Loading } from '@/components/Loading' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Button } from '@/components/ui/button' @@ -60,7 +61,7 @@ export function StakingDetails({ validator, constraints, stakesByValidator }: St value: convertFromBaseUnits(Number(pool.totalAlgoStaked || 1n), 6), })) || [] - const { stakersChartData, poolsInfo, isLoading, isError } = useStakersChartData({ + const { stakersChartData, poolsInfo, isLoading, errorMessage } = useStakersChartData({ selectedPool, validatorId: validator.id, }) @@ -296,8 +297,8 @@ export function StakingDetails({ validator, constraints, stakesByValidator }: St return } - if (isError) { - return
Error
+ if (errorMessage) { + return } return ( diff --git a/ui/src/hooks/useStakersChartData.ts b/ui/src/hooks/useStakersChartData.ts index c9799c4a..20255e44 100644 --- a/ui/src/hooks/useStakersChartData.ts +++ b/ui/src/hooks/useStakersChartData.ts @@ -18,8 +18,15 @@ export function useStakersChartData({ selectedPool, validatorId }: UseChartDataP }) const isLoading = poolsInfoQuery.isLoading || allStakedInfo.some((query) => query.isLoading) - const isError = poolsInfoQuery.isError || allStakedInfo.some((query) => query.isError) const isSuccess = poolsInfoQuery.isSuccess && allStakedInfo.every((query) => query.isSuccess) + const isError = poolsInfoQuery.isError || allStakedInfo.some((query) => query.isError) + + const defaultMessage = isError ? 'An error occurred while loading staking data.' : undefined + + const errorMessage = + poolsInfoQuery.error?.message || + allStakedInfo.find((query) => query.error)?.error?.message || + defaultMessage const stakersChartData = React.useMemo(() => { if (!allStakedInfo) { @@ -64,6 +71,7 @@ export function useStakersChartData({ selectedPool, validatorId }: UseChartDataP poolsInfo, isLoading, isError, + errorMessage, isSuccess, } } From 377af19bf688aa151724eb6d33e8a736f6b66fae Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 7 May 2024 16:21:31 -0400 Subject: [PATCH 4/6] feat(ui): use ErrorAlert on add validator page --- ui/src/routes/add.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ui/src/routes/add.tsx b/ui/src/routes/add.tsx index d82ef67d..52a0c847 100644 --- a/ui/src/routes/add.tsx +++ b/ui/src/routes/add.tsx @@ -2,6 +2,7 @@ import { useSuspenseQuery } from '@tanstack/react-query' import { Navigate, createFileRoute, redirect } from '@tanstack/react-router' import { useWallet } from '@txnlab/use-wallet-react' import { constraintsQueryOptions } from '@/api/queries' +import { ErrorAlert } from '@/components/ErrorAlert' import { Loading } from '@/components/Loading' import { Meta } from '@/components/Meta' import { PageHeader } from '@/components/PageHeader' @@ -20,10 +21,12 @@ export const Route = createFileRoute('/add')({ component: AddValidator, pendingComponent: () => , errorComponent: ({ error }) => { - if (error instanceof Error) { - return
{error?.message}
- } - return
Error loading protocol constraints
+ const defaultMessage = 'See console for error details.' + const message = + error instanceof Error + ? `Error loading protocol constraints: ${error?.message || defaultMessage}` + : defaultMessage + return }, }) From 0606447a39febc301185c11d84555fe9011032fa Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 7 May 2024 22:55:40 -0400 Subject: [PATCH 5/6] feat(ui): collapsible numbered steps for adding pools This also improves the entire workflow and enhances validation to include balance checks. --- pnpm-lock.yaml | 31 ++++ ui/package.json | 1 + ui/src/components/AddPoolModal.tsx | 235 +++++++++++++++++++-------- ui/src/components/NodeSelect.tsx | 22 +-- ui/src/components/ui/collapsible.tsx | 9 + ui/src/styles/main.css | 33 ++++ 6 files changed, 250 insertions(+), 81 deletions(-) create mode 100644 ui/src/components/ui/collapsible.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eff275bc..6253d3c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,6 +117,9 @@ importers: '@radix-ui/react-checkbox': specifier: 1.0.4 version: 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-collapsible': + specifier: ^1.0.3 + version: 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) '@radix-ui/react-dialog': specifier: 1.0.5 version: 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) @@ -1915,6 +1918,34 @@ packages: react-dom: 18.3.1(react@18.3.1) dev: false + /@radix-ui/react-collapsible@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-UBmVDkmR6IvDsloHVN+3rtx4Mi5TFvylYXpluuv0f37dtaz3H99bp8No0LGXRigVpl3UAT4l9j6bIchh42S/Gg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.5 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.1)(react@18.3.1) + '@types/react': 18.3.1 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + /@radix-ui/react-collection@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.1)(react-dom@18.3.1)(react@18.3.1): resolution: {integrity: sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==} peerDependencies: diff --git a/ui/package.json b/ui/package.json index 35f74404..dce981dd 100644 --- a/ui/package.json +++ b/ui/package.json @@ -45,6 +45,7 @@ "@perawallet/connect": "1.3.4", "@radix-ui/react-avatar": "1.0.4", "@radix-ui/react-checkbox": "1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", "@radix-ui/react-dialog": "1.0.5", "@radix-ui/react-dropdown-menu": "2.0.6", "@radix-ui/react-hover-card": "1.0.7", diff --git a/ui/src/components/AddPoolModal.tsx b/ui/src/components/AddPoolModal.tsx index ee4f83be..1832baef 100644 --- a/ui/src/components/AddPoolModal.tsx +++ b/ui/src/components/AddPoolModal.tsx @@ -1,3 +1,4 @@ +import { AlgoAmount } from '@algorandfoundation/algokit-utils/types/amount' import { zodResolver } from '@hookform/resolvers/zod' import { useQuery, useQueryClient } from '@tanstack/react-query' import { ProgressBar } from '@tremor/react' @@ -6,11 +7,13 @@ import * as React from 'react' import { useForm } from 'react-hook-form' import { toast } from 'sonner' import { z } from 'zod' +import { fetchAccountInformation } from '@/api/algod' import { addStakingPool, fetchValidator, initStakingPoolStorage } from '@/api/contracts' import { mbrQueryOptions, poolAssignmentQueryOptions } from '@/api/queries' import { AlgoDisplayAmount } from '@/components/AlgoDisplayAmount' import { NodeSelect } from '@/components/NodeSelect' import { Button } from '@/components/ui/button' +import { Collapsible, CollapsibleContent } from '@/components/ui/collapsible' import { Dialog, DialogContent, @@ -28,13 +31,14 @@ import { FormMessage, } from '@/components/ui/form' import { NodePoolAssignmentConfig, Validator, ValidatorPoolKey } from '@/interfaces/validator' -import { findFirstAvailableNode, setValidatorQueriesData } from '@/utils/contracts' +import { + findFirstAvailableNode, + processNodePoolAssignment, + setValidatorQueriesData, +} from '@/utils/contracts' +import { formatAlgoAmount } from '@/utils/format' import { cn } from '@/utils/ui' -const formSchema = z.object({ - nodeNum: z.string(), -}) - interface AddPoolModalProps { validator: Validator | null setValidator: React.Dispatch> @@ -50,19 +54,20 @@ export function AddPoolModal({ const [progress, setProgress] = React.useState(0) const [currentStep, setCurrentStep] = React.useState(1) const [poolKey, setPoolKey] = React.useState(null) + const [isInitMbrError, setIsInitMbrError] = React.useState(undefined) const queryClient = useQueryClient() const { transactionSigner, activeAddress } = useWallet() - const form = useForm>({ - resolver: zodResolver(formSchema), - mode: 'onChange', - defaultValues: { - nodeNum: '1', - }, + const accountInfoQuery = useQuery({ + queryKey: ['account-info', activeAddress], + queryFn: () => fetchAccountInformation(activeAddress!), + enabled: !!activeAddress && !!validator, // wait until modal is open }) - const { isValid } = form.formState + const { amount = 0, 'min-balance': minBalance = 0 } = accountInfoQuery.data || {} + + const availableBalance = Math.max(0, amount - minBalance) const mbrQuery = useQuery(mbrQueryOptions) const { poolMbr = 0, poolInitMbr = 0 } = mbrQuery.data || {} @@ -70,6 +75,13 @@ export function AddPoolModal({ const assignmentQuery = useQuery(poolAssignmentQueryOptions(validator?.id || '', !!validator)) const poolAssignment = assignmentQuery.data || poolAssignmentProp + const nodesInfo = React.useMemo(() => { + if (!poolAssignment || !validator) { + return [] + } + return processNodePoolAssignment(poolAssignment, validator?.config.poolsPerNode) + }, [poolAssignment, validator?.config.poolsPerNode]) + const defaultNodeNum = React.useMemo(() => { if (!validator?.config.poolsPerNode || !poolAssignment) { return '1' @@ -78,12 +90,49 @@ export function AddPoolModal({ return nodeNum?.toString() || '1' }, [poolAssignment, validator?.config.poolsPerNode]) + const formSchema = z.object({ + nodeNum: z + .string() + .refine((val) => val !== '', { + message: 'Required field', + }) + .refine(() => availableBalance >= poolMbr, { + message: `Insufficient balance: ${formatAlgoAmount(AlgoAmount.MicroAlgos(poolMbr).algos)} ALGO required`, + }) + .refine( + (val) => { + if (!validator) return false + return nodesInfo.some((node) => node.index === Number(val) && node.availableSlots > 0) + }, + { + message: 'Node has no available slots', + }, + ), + }) + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + nodeNum: defaultNodeNum, + }, + }) + + const { errors } = form.formState + + const nodeNum = form.watch('nodeNum') + + React.useEffect(() => { + if (validator !== null && currentStep == 1 && nodeNum !== '' && !errors.nodeNum) { + setProgress(33) + } + }, [validator, currentStep, nodeNum, errors.nodeNum]) + React.useEffect(() => { form.setValue('nodeNum', defaultNodeNum) }, [defaultNodeNum, form.setValue]) const handleResetForm = () => { - form.reset({ nodeNum: '1' }) + form.reset({ nodeNum: defaultNodeNum }) form.clearErrors() setProgress(0) setCurrentStep(1) @@ -107,8 +156,6 @@ export function AddPoolModal({ const toastId = `${TOAST_ID}-add-pool` try { - setIsSigning(true) - if (!activeAddress) { throw new Error('No active address') } @@ -119,6 +166,8 @@ export function AddPoolModal({ toast.loading('Sign transactions to add staking pool...', { id: toastId }) + setIsSigning(true) + const stakingPoolKey = await addStakingPool( validator!.id, Number(data.nodeNum), @@ -127,20 +176,23 @@ export function AddPoolModal({ activeAddress, ) + setIsSigning(false) setPoolKey(stakingPoolKey) toast.success(`Staking pool ${stakingPoolKey.poolId} created!`, { id: toastId, duration: 5000, }) - setProgress(50) + + // Refetch account info to get new available balance for MBR payment + await accountInfoQuery.refetch() + + setProgress(68) setCurrentStep(2) } catch (error) { toast.error('Failed to create staking pool', { id: toastId }) console.error(error) handleOpenChange(false) - } finally { - setIsSigning(false) } } @@ -150,8 +202,6 @@ export function AddPoolModal({ const toastId = `${TOAST_ID}-pay-mbr` try { - setIsSigning(true) - if (!activeAddress) { throw new Error('No active address') } @@ -164,6 +214,12 @@ export function AddPoolModal({ throw new Error('No MBR data found') } + if (availableBalance < poolInitMbr) { + throw new Error( + `Insufficient balance: ${formatAlgoAmount(AlgoAmount.MicroAlgos(poolInitMbr).algos)} ALGO required`, + ) + } + toast.loading(`Sign transaction to pay MBR for pool ${poolKey.poolId}...`, { id: toastId, }) @@ -171,6 +227,8 @@ export function AddPoolModal({ const optInRewardToken = validator?.config.rewardTokenId !== 0 && validator?.state.numPools === 0 + setIsSigning(true) + await initStakingPoolStorage( poolKey.poolAppId, poolInitMbr, @@ -179,27 +237,38 @@ export function AddPoolModal({ activeAddress, ) + setIsSigning(false) + toast.success(`Pool ${poolKey.poolId} MBR paid successfully!`, { id: toastId, duration: 5000, }) - setProgress(100) // Refetch validator data const newData = await fetchValidator(validator!.id) setValidatorQueriesData(queryClient, newData) - setTimeout(() => handleOpenChange(false), 1000) - } catch (error) { - toast.error('Failed to pay MBR', { id: toastId }) + setProgress(100) + setCurrentStep(3) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + toast.error('Minimum required balance payment failed', { id: toastId }) console.error(error) - setIsSigning(false) + setIsInitMbrError(error?.message) } } + const handleComplete = (event: React.MouseEvent) => { + event.preventDefault() + handleOpenChange(false) + } + return ( - event.preventDefault()}> + event.preventDefault()} + onInteractOutside={(event: Event) => event.preventDefault()} + > Add a Pool @@ -209,53 +278,79 @@ export function AddPoolModal({
- ( - - Select Node - {poolAssignment && !!validator && ( - - )} - - Select a node with an available slot (max: {validator?.config.poolsPerNode}) - - - - )} - /> - {currentStep == 2 && ( -
- Pay Minimum Required Balance -

- To initialize the staking pool, a{' '} - {' '} - MBR payment is required. -

-
- )} +
+ 1 })} + > + + Select Node + + ( + + + + Select a node with an available slot (max:{' '} + {validator?.config.poolsPerNode}) + + {errors.nodeNum?.message} + + )} + /> + + + + 2 })} + > + + Pay Minimum Required Balance + + +
+

+ To initialize the staking pool, a{' '} + {' '} + MBR payment is required. +

+ + {isInitMbrError} + +
+
+
+
- {currentStep === 1 && ( - )} - {currentStep === 2 && ( - + {currentStep == 2 && ( + + )} + {currentStep == 3 && ( + )} diff --git a/ui/src/components/NodeSelect.tsx b/ui/src/components/NodeSelect.tsx index 38f5fd93..a3e4c61c 100644 --- a/ui/src/components/NodeSelect.tsx +++ b/ui/src/components/NodeSelect.tsx @@ -6,31 +6,31 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' -import { NodePoolAssignmentConfig } from '@/interfaces/validator' -import { processNodePoolAssignment } from '@/utils/contracts' +import { NodeInfo } from '@/interfaces/validator' interface NodeSelectProps { - nodes: NodePoolAssignmentConfig - poolsPerNode: number + nodesInfo: NodeInfo[] + value: string onValueChange: (value: string) => void - defaultValue: string } -export function NodeSelect({ nodes, poolsPerNode, onValueChange, defaultValue }: NodeSelectProps) { - const nodeInfo = processNodePoolAssignment(nodes, poolsPerNode) - +export function NodeSelect({ nodesInfo, value, onValueChange }: NodeSelectProps) { return ( - - {nodeInfo.map(({ index, availableSlots }) => ( + {nodesInfo.map(({ index, availableSlots }) => ( Node {index}{' '} - ({availableSlots === 0 ? 'no slots remaining' : `${availableSlots} slots`}) + ( + {availableSlots === 0 + ? 'no slots remaining' + : `${availableSlots} slot${availableSlots > 1 ? 's' : ''}`} + ) ))} diff --git a/ui/src/components/ui/collapsible.tsx b/ui/src/components/ui/collapsible.tsx new file mode 100644 index 00000000..7cee61ef --- /dev/null +++ b/ui/src/components/ui/collapsible.tsx @@ -0,0 +1,9 @@ +import * as CollapsiblePrimitive from '@radix-ui/react-collapsible' + +const Collapsible = CollapsiblePrimitive.Root + +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger + +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/ui/src/styles/main.css b/ui/src/styles/main.css index 73aef0f7..892bbab4 100644 --- a/ui/src/styles/main.css +++ b/ui/src/styles/main.css @@ -73,3 +73,36 @@ url('/fonts/Algo.woff') format('woff'), url('/fonts/Algo.ttf') format('truetype'); } + +.\[\&\>div\>label\]\:step>div>label { + counter-increment: step +} + +.\[\&\>div\>label\]\:step>div>label:before { + position: absolute; + display: inline-flex; + height: 2.25rem; + width: 2.25rem; + align-items: center; + justify-content: center; + border-radius: 9999px; + border-width: 4px; + border-color: hsl(var(--background)); + background-color: hsl(var(--muted)); + text-align: center; + text-indent: -1px; + font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; + font-size: 1rem; + line-height: 1.5rem; + font-weight: 400; + margin-left: -50px; + margin-top: -4px; + content: counter(step); +} + +.\[\&\>div\>label\]\:step>div.completed>label:before { + background-color: theme(colors.green.900); + color: theme(colors.green.500); + content: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMmM1NWUiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHlsZT0ibWFyZ2luLXRvcDogMnB4OyI+PHBhdGggZD0iTTIwIDYgOSAxN2wtNS01Ii8+PC9zdmc+'); + font-size: 1.25rem; +} From b280df35b5d4fe21e45d15d851efed825bb1e35f Mon Sep 17 00:00:00 2001 From: Doug Richar Date: Tue, 7 May 2024 23:13:22 -0400 Subject: [PATCH 6/6] chore(ui): run Prettier on main.css --- ui/src/styles/main.css | 55 +++++++++++++++++++++--------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/ui/src/styles/main.css b/ui/src/styles/main.css index 892bbab4..b3ca8f7a 100644 --- a/ui/src/styles/main.css +++ b/ui/src/styles/main.css @@ -74,35 +74,36 @@ url('/fonts/Algo.ttf') format('truetype'); } -.\[\&\>div\>label\]\:step>div>label { - counter-increment: step +.\[\&\>div\>label\]\:step > div > label { + counter-increment: step; } -.\[\&\>div\>label\]\:step>div>label:before { - position: absolute; - display: inline-flex; - height: 2.25rem; - width: 2.25rem; - align-items: center; - justify-content: center; - border-radius: 9999px; - border-width: 4px; - border-color: hsl(var(--background)); - background-color: hsl(var(--muted)); - text-align: center; - text-indent: -1px; - font-family: ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; - font-size: 1rem; - line-height: 1.5rem; - font-weight: 400; - margin-left: -50px; - margin-top: -4px; - content: counter(step); +.\[\&\>div\>label\]\:step > div > label:before { + position: absolute; + display: inline-flex; + height: 2.25rem; + width: 2.25rem; + align-items: center; + justify-content: center; + border-radius: 9999px; + border-width: 4px; + border-color: hsl(var(--background)); + background-color: hsl(var(--muted)); + text-align: center; + text-indent: -1px; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; + font-size: 1rem; + line-height: 1.5rem; + font-weight: 400; + margin-left: -50px; + margin-top: -4px; + content: counter(step); } -.\[\&\>div\>label\]\:step>div.completed>label:before { - background-color: theme(colors.green.900); - color: theme(colors.green.500); - content: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMmM1NWUiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHlsZT0ibWFyZ2luLXRvcDogMnB4OyI+PHBhdGggZD0iTTIwIDYgOSAxN2wtNS01Ii8+PC9zdmc+'); - font-size: 1.25rem; +.\[\&\>div\>label\]\:step > div.completed > label:before { + background-color: theme(colors.green.900); + color: theme(colors.green.500); + content: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9IiMyMmM1NWUiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBzdHlsZT0ibWFyZ2luLXRvcDogMnB4OyI+PHBhdGggZD0iTTIwIDYgOSAxN2wtNS01Ii8+PC9zdmc+'); + font-size: 1.25rem; }