-
Notifications
You must be signed in to change notification settings - Fork 478
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
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
9b9accf
feat: Add StepperFactory, new tx pages
usame-algan 496277d
wip: context instead of routes
usame-algan f8bbf38
Add tx flows to new modal context
usame-algan f849155
fix: Close modal when user navigates
usame-algan 0443d9d
Export all flows from one file
usame-algan 144f636
refactor: Remove unused code from StepperFactory
usame-algan eb45daf
fix: Duplicate ModalDialog to fix failing tests
usame-algan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} | ||
|
||
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> | ||
) | ||
} |
10 changes: 8 additions & 2 deletions
10
src/components/tx/TxFlow/NewTxMenu.tsx → src/components/TxFlow/NewTx/index.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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
they would do
Not 100% convinced tho.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.