Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add StepperFactory and new tx layout #2040

Merged
merged 7 commits into from
May 30, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions src/components/TxFlow/ModalProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import {
createContext,
type Dispatch,
type ReactElement,
type ReactNode,
type SetStateAction,
useState,
type ComponentProps,
useEffect,
} from 'react'
import NewModalDialog from '@/components/common/NewModalDialog'
import { ReplaceTxMenu, NewTxMenu, RejectTx, TokenTransferFlow } from '@/components/TxFlow'
import { useRouter } from 'next/router'

export enum ModalType {
SendTokens = 'sendTokens',
RejectTx = 'rejectTx',
ReplaceTx = 'replaceTx',
NewTx = 'newTx',
}

const ModalTypes = {
[ModalType.SendTokens]: TokenTransferFlow,
[ModalType.RejectTx]: RejectTx,
[ModalType.ReplaceTx]: ReplaceTxMenu,
[ModalType.NewTx]: NewTxMenu,
}
Comment on lines +15 to +27
Copy link
Member

@katspaugh katspaugh May 26, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've just realized it's probably easier to just let modal-opening buttons pass TokenTransferFlow directly instead of a string. That would make the dependency explicit.

So instead of

onClick={() => setVisibleModal({ type: ModalType.SendTokens, props: {} })}

they would do

onClick={() => setVisibleModal(<TokenTransferFlow nonce={nonce} />)}

Not 100% convinced tho.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation we opted for aimed to narrow the possible components we want to show in the modal. It comes at the cost of maintaining the map but setting a component in the dispatcher would make harder to reinforce the types.
We are also not sold on storing the component as state.

Copy link
Member

@katspaugh katspaugh May 30, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can still limit the type of components setVisibleModal accepts.
Something like T extends TxFlowComponent, so that it accepts any component whose signature satisfies TxFlowComponent.


type VisibleModalState<T extends ModalType> = {
type: T
props: ComponentProps<typeof ModalTypes[T]>
}

type ContextProps<T extends ModalType> = {
visibleModal: VisibleModalState<T> | undefined
setVisibleModal: Dispatch<SetStateAction<VisibleModalState<T> | undefined>>
}

export const ModalContext = createContext<ContextProps<ModalType>>({
visibleModal: undefined,
setVisibleModal: () => {},
})

export const ModalProvider = ({ children }: { children: ReactNode }): ReactElement => {
const [visibleModal, setVisibleModal] = useState<VisibleModalState<ModalType>>()
const router = useRouter()

const Component = visibleModal ? ModalTypes[visibleModal.type] : null
const props = visibleModal ? visibleModal.props : {}

// Close the modal if user navigates
useEffect(() => {
router.events.on('routeChangeComplete', () => {
setVisibleModal(undefined)
})
}, [router])

return (
<ModalContext.Provider value={{ visibleModal, setVisibleModal }}>
{children}
<NewModalDialog open={!!visibleModal}>
{/* @ts-ignore TODO: Fix this somehow */}
{visibleModal && <Component {...props} />}
</NewModalDialog>
</ModalContext.Provider>
)
}
Original file line number Diff line number Diff line change
@@ -1,18 +1,24 @@
import TxButton, { SendNFTsButton, SendTokensButton } from '@/components/tx/modals/NewTxModal/TxButton'
import TxButton, { SendNFTsButton, SendTokensButton } from '@/components/TxFlow/common/TxButton'
import { useTxBuilderApp } from '@/hooks/safe-apps/useTxBuilderApp'
import { Box, Typography } from '@mui/material'
import { ModalContext, ModalType } from '@/components/TxFlow/ModalProvider'
import { useContext } from 'react'

const BUTTONS_HEIGHT = '91px'

const NewTxMenu = () => {
const { setVisibleModal } = useContext(ModalContext)
const txBuilder = useTxBuilderApp()

return (
<Box display="flex" flexDirection="column" alignItems="center" gap={2} width={452} m="auto">
<Typography variant="h6" fontWeight={700}>
New transaction
</Typography>
<SendTokensButton onClick={() => console.log('open send funds flow')} sx={{ height: BUTTONS_HEIGHT }} />
<SendTokensButton
onClick={() => setVisibleModal({ type: ModalType.SendTokens, props: {} })}
sx={{ height: BUTTONS_HEIGHT }}
/>

<SendNFTsButton onClick={() => console.log('open send NFTs flow')} sx={{ height: BUTTONS_HEIGHT }} />

Expand Down
38 changes: 38 additions & 0 deletions src/components/TxFlow/RejectTx/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { ReactElement } from 'react'
import { Typography } from '@mui/material'
import type { SafeTransaction } from '@safe-global/safe-core-sdk-types'

import useAsync from '@/hooks/useAsync'
import SignOrExecuteForm from '@/components/tx/SignOrExecuteForm'
import { createRejectTx } from '@/services/tx/tx-sender'
import TxLayout from '@/components/TxFlow/common/TxLayout'

type RejectTxProps = {
txNonce: number
}

const RejectTx = ({ txNonce }: RejectTxProps): ReactElement => {
const [rejectTx, rejectError] = useAsync<SafeTransaction>(() => {
return createRejectTx(txNonce)
}, [txNonce])

return (
<TxLayout title="Reject transaction">
<SignOrExecuteForm safeTx={rejectTx} isRejection onSubmit={() => {}} error={rejectError}>
<Typography mb={2}>
To reject the transaction, a separate rejection transaction will be created to replace the original one.
</Typography>

<Typography mb={2}>
Transaction nonce: <b>{txNonce}</b>
</Typography>

<Typography mb={2}>
You will need to confirm the rejection transaction with your currently connected wallet.
</Typography>
</SignOrExecuteForm>
</TxLayout>
)
}

export default RejectTx
150 changes: 150 additions & 0 deletions src/components/TxFlow/ReplaceTx/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import { Button, Container, Grid, Paper, Step, StepLabel, Stepper, SvgIcon, Tooltip, Typography } from '@mui/material'

import InfoIcon from '@/public/images/notifications/info.svg'
import RocketIcon from '@/public/images/transactions/rocket.svg'
import CheckIcon from '@mui/icons-material/Check'
import DeleteIcon from '@/public/images/common/delete.svg'
import { SendTokensButton } from '@/components/TxFlow/common/TxButton'
import { useQueuedTxByNonce } from '@/hooks/useTxQueue'
import { isCustomTxInfo } from '@/utils/transaction-guards'

import css from './styles.module.css'
import { useContext } from 'react'
import { ModalContext, ModalType } from '@/components/TxFlow/ModalProvider'

const wrapIcon = (icon: React.ReactNode) => <div className={css.circle}>{icon}</div>

const steps = [
{
label: 'Create new transaction with same nonce',
icon: <div className={css.redCircle} />,
},
{
label: 'Collect confirmations from owners',
icon: wrapIcon(<CheckIcon fontSize="small" color="border" />),
},
{
label: 'Execute replacement transaction',
icon: wrapIcon(<SvgIcon component={RocketIcon} inheritViewBox fontSize="small" color="border" />),
},
{
label: 'Initial transaction is replaced',
icon: wrapIcon(<SvgIcon component={DeleteIcon} inheritViewBox fontSize="small" color="border" />),
},
]

const btnWidth = {
width: {
xs: 240,
sm: '100%',
},
}

const ReplaceTxMenu = ({ txNonce }: { txNonce: number }) => {
const { setVisibleModal } = useContext(ModalContext)
const queuedTxsByNonce = useQueuedTxByNonce(txNonce)
const canCancel = !queuedTxsByNonce?.some(
(item) => isCustomTxInfo(item.transaction.txInfo) && item.transaction.txInfo.isCancellation,
)

return (
<Container>
<Grid container justifyContent="center">
<Grid item component={Paper} xs={8}>
<div className={css.container}>
<Typography variant="h5" mb={1} textAlign="center">
Need to replace or discard this transaction?
</Typography>
<Typography variant="body1" textAlign="center">
A signed transaction cannot be removed but it can be replaced with a new transaction with the same nonce.
</Typography>
<Stepper alternativeLabel className={css.stepper}>
{steps.map(({ label }) => (
<Step key={label}>
<StepLabel StepIconComponent={({ icon }) => steps[Number(icon) - 1].icon}>
<Typography variant="body1" fontWeight={700}>
{label}
</Typography>
</StepLabel>
</Step>
))}
</Stepper>
</div>
<div className={css.container}>
<Grid container alignItems="center" justifyContent="center" flexDirection="row">
<Grid item xs={12}>
<Typography variant="body2" textAlign="center" fontWeight={700} mb={3}>
Select how you would like to replace this transaction
</Typography>
</Grid>
<Grid item container justifyContent="center" alignItems="center" gap={1} xs={12} sm flexDirection="row">
<SendTokensButton
onClick={() => setVisibleModal({ type: ModalType.SendTokens, props: { txNonce } })}
sx={btnWidth}
/>
</Grid>
<Grid item>
<Typography variant="body2" className={css.or}>
or
</Typography>
</Grid>
<Grid
item
container
xs={12}
sm
justifyContent={{
xs: 'center',
sm: 'flex-start',
}}
alignItems="center"
textAlign="center"
flexDirection="row"
>
<Tooltip
arrow
placement="top"
title={canCancel ? '' : `Transaction with nonce ${txNonce} already has a reject transaction`}
>
<span style={{ width: '100%' }}>
<Button
onClick={() => setVisibleModal({ type: ModalType.RejectTx, props: { txNonce } })}
variant="outlined"
fullWidth
sx={{ mb: 1, ...btnWidth }}
disabled={!canCancel}
>
Reject transaction
</Button>
</span>
</Tooltip>

<div>
<Typography variant="caption" display="flex" alignItems="center">
How does it work?{' '}
<Tooltip
title={`An on-chain rejection doesn't send any funds. Executing an on-chain rejection will replace all currently awaiting transactions with nonce ${txNonce}.`}
arrow
>
<span>
<SvgIcon
component={InfoIcon}
inheritViewBox
fontSize="small"
color="border"
sx={{ verticalAlign: 'middle', ml: 0.5 }}
/>
</span>
</Tooltip>
</Typography>
</div>
</Grid>
</Grid>
</div>
</Grid>
</Grid>
</Container>
)
}

export default ReplaceTxMenu
109 changes: 109 additions & 0 deletions src/components/TxFlow/StepperFactory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import merge from 'lodash/merge'
import { createContext, Suspense, useCallback, useState } from 'react'
import type { JSXElementConstructor, ReactElement, Dispatch, SetStateAction } from 'react'

type Object = Record<string, unknown>

type MergeObjects<T extends Array<Object>> = T extends [a: infer A, ...rest: infer R]
? R extends Array<Object>
? A & MergeObjects<R>
: never
: {}

type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never

type ContextProps<T extends Array<Object>> = {
activeStep: number
setActiveStep: Dispatch<SetStateAction<number>>
progress: number
stepValues: Object
defaultValues: T[number]
mergedValues: Expand<MergeObjects<T>>
onBack: () => void
onSubmit: (stepValues: T[number]) => void
}

const DEFAULT_ACTIVE_STEP = 0

export const createStepper = <T extends Array<Object>>() => {
// Typed context
const Context = createContext<ContextProps<T>>({
activeStep: DEFAULT_ACTIVE_STEP,
setActiveStep: () => {},
progress: 0,
stepValues: {},
defaultValues: {} as T[number],
mergedValues: {} as Expand<MergeObjects<T>>,
onBack: () => {},
onSubmit: () => {},
})

// Typed Provider
const Provider = <S extends ReactElement<any, string | JSXElementConstructor<any>>>({
onClose,
steps,
defaultValues,
children,
}: {
onClose?: () => void
steps: S[]
defaultValues: T
children?: (Step: S, values: ContextProps<T>) => ReactElement
}) => {
const [activeStep, setActiveStep] = useState(DEFAULT_ACTIVE_STEP)
const [values, setValues] = useState<T>(defaultValues)

const mergedValues: Expand<MergeObjects<T>> = merge({}, ...values)

const onBack = useCallback(() => {
const isFirstStep = activeStep === DEFAULT_ACTIVE_STEP
if (isFirstStep) {
onClose?.()
return
}

setActiveStep(activeStep - 1)
}, [activeStep, onClose])

const onSubmit = (stepValues: T[number]) => {
const isLastStep = activeStep === steps.length - 1
if (isLastStep) {
onClose?.()
return
}

setValues((prevValues) => {
prevValues[activeStep] = stepValues
return prevValues
})

setActiveStep((prevStep) => prevStep + 1)
}

const progress = ((activeStep + 1) / steps.length) * 100

const Step = steps[activeStep]

const providerValues: ContextProps<T> = {
activeStep,
setActiveStep,
progress,
stepValues: values[activeStep],
defaultValues: defaultValues[activeStep],
mergedValues,
onBack,
onSubmit,
}

return (
<Context.Provider value={providerValues}>
<Suspense>{children ? children(Step, providerValues) : Step}</Suspense>
</Context.Provider>
)
}

return {
Provider,
Context,
}
}
Loading