Skip to content

Commit

Permalink
Refactor Modal so that
Browse files Browse the repository at this point in the history
 - The state is handled in Redux
 - No more ModalProvider
 - pop-up in pop-up is now supported (recursively)
  • Loading branch information
csillag committed Oct 20, 2022
1 parent f29b336 commit 39da44e
Show file tree
Hide file tree
Showing 12 changed files with 139 additions and 122 deletions.
7 changes: 3 additions & 4 deletions src/app/components/AddEscrowForm/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { ModalProvider } from 'app/components/Modal'
import { ModalContainer } from 'app/components/Modal'
import { Validator } from 'app/state/staking/types'
import * as React from 'react'
import { Provider } from 'react-redux'
Expand All @@ -12,9 +12,8 @@ import { AddEscrowForm } from '..'
const renderComponent = (store: any, address: string, validatorStatus: Validator['status']) =>
render(
<Provider store={store}>
<ModalProvider>
<AddEscrowForm validatorAddress={address} validatorStatus={validatorStatus} validatorRank={21} />
</ModalProvider>
<AddEscrowForm validatorAddress={address} validatorStatus={validatorStatus} validatorRank={21} />
<ModalContainer />
</Provider>,
)

Expand Down
5 changes: 3 additions & 2 deletions src/app/components/AddEscrowForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
* AddEscrowForm
*
*/
import { useModal } from 'app/components/Modal'
import { Modal } from '../Modal/slice/types'
import { modalActions } from '../Modal/slice'
import { parseRoseStringToBaseUnitString } from 'app/lib/helpers'
import { selectMinStaking } from 'app/state/network/selectors'
import { Validator } from 'app/state/staking/types'
Expand All @@ -24,7 +25,7 @@ interface Props {

export const AddEscrowForm = memo((props: Props) => {
const { t } = useTranslation()
const { launchModal } = useModal()
const launchModal = (modal: Modal) => dispatch(modalActions.launch(modal))
const { error, success } = useSelector(selectTransaction)
const isTop20 = props.validatorRank <= 20
const [showNotice, setShowNotice] = useState(isTop20)
Expand Down
123 changes: 17 additions & 106 deletions src/app/components/Modal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import { Box, Button, Layer, Paragraph } from 'grommet'
import { useTranslation } from 'react-i18next'
import { Alert, Checkmark, Close, Configure } from 'grommet-icons'
Expand All @@ -7,81 +7,32 @@ import { AlertBox } from '../AlertBox'
import { selectAllowDangerousSetting } from '../SettingsDialog/slice/selectors'
import { useDispatch, useSelector } from 'react-redux'
import { settingsActions } from '../SettingsDialog/slice'
import { selectCurrentModal } from './slice/selectors'
import { modalActions } from './slice'

interface Modal {
title: string
description: string
handleConfirm: () => void

/**
* Is this a dangerous operation?
*
* If marked as such, it will only be possible to execute it if the wallet is configured to run in dangerous mode.
*
* It also automatically implies a mandatory waiting time of 10 sec, unless specified otherwise.
*/
isDangerous: boolean

/**
* How long does the user have to wait before he can actually confirm the action?
*/
mustWaitSecs?: number
}

interface ModalContainerProps {
modal: Modal
closeModal: () => void
hideModal: () => void
}

interface ModalContextProps {
/**
* Show a new modal
*/
launchModal: (modal: Modal) => void

/**
* Close the current modal
*/
closeModal: () => void

/**
* Hide the current modal (with the intention of showing in again later)
*/
hideModal: () => void

/**
* Show the previously hidden modal again
*/
showModal: () => void
}

interface ModalProviderProps {
children: React.ReactNode
}

const ModalContext = createContext<ModalContextProps>({} as ModalContextProps)

const ModalContainer = ({ modal, closeModal, hideModal }: ModalContainerProps) => {
const ModalContainer = () => {
const dispatch = useDispatch()
const { t } = useTranslation()
const confirm = useCallback(() => {
modal.handleConfirm()
closeModal()
}, [closeModal, modal])
const { isDangerous, mustWaitSecs } = modal
const modal = useSelector(selectCurrentModal)
const allowDangerous = useSelector(selectAllowDangerousSetting)
const [secsLeft, setSecsLeft] = useState(0)
const closeModal = () => dispatch(modalActions.close())
const confirm = () => {
modal?.handleConfirm()
dispatch(modalActions.close())
}
const { isDangerous, mustWaitSecs } = modal || {}

const forbidden = isDangerous && !allowDangerous
const waitingTime = forbidden
? 0 // If the action is forbidden, there is nothing to wait for
: isDangerous
? mustWaitSecs ?? 10 // For dangerous actions, we require 10 seconds of waiting, unless specified otherwise.
: mustWaitSecs ?? 0 // For normal, non-dangerous operations, just use what was specified

const [secsLeft, setSecsLeft] = useState(0)
const openSettings = () => {
hideModal()
setTimeout(() => dispatch(settingsActions.setOpen(true)), 100)
dispatch(modalActions.stash())
dispatch(settingsActions.setOpen(true))
}

useEffect(() => {
Expand All @@ -101,6 +52,7 @@ const ModalContainer = ({ modal, closeModal, hideModal }: ModalContainerProps) =
}
}, [waitingTime])

if (!modal) return null
return (
<Layer modal onEsc={closeModal} onClickOutside={closeModal} background="background-front">
<Box margin="medium">
Expand Down Expand Up @@ -148,45 +100,4 @@ const ModalContainer = ({ modal, closeModal, hideModal }: ModalContainerProps) =
)
}

const ModalProvider = (props: ModalProviderProps) => {
const [modal, setModal] = useState<Modal | null>(null)
const [hiddenModal, setHiddenModal] = useState<Modal | null>(null)
const closeModal = useCallback(() => {
setModal(null)
}, [])
const hideModal = useCallback(() => {
if (!modal) {
throw new Error("You can't call hideModal if no model is shown!")
}
setHiddenModal(modal)
setModal(null)
}, [modal])
const showModal = useCallback(() => {
if (modal) {
throw new Error("You can't call showModal when a modal is already visible!")
}
if (!hiddenModal) {
return
}
setModal(hiddenModal)
setHiddenModal(null)
}, [modal, hiddenModal])

return (
<ModalContext.Provider value={{ closeModal, launchModal: setModal, hideModal, showModal }}>
{props.children}
{modal && <ModalContainer modal={modal} closeModal={closeModal} hideModal={hideModal} />}
</ModalContext.Provider>
)
}

const useModal = () => {
const context = useContext(ModalContext)
if (context === undefined) {
throw new Error('useModal must be used within a ModalProvider')
}

return context
}

export { ModalProvider, useModal }
export { ModalContainer }
63 changes: 63 additions & 0 deletions src/app/components/Modal/slice/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { PayloadAction } from '@reduxjs/toolkit'
import { createSlice } from 'utils/@reduxjs/toolkit'

import { Modal, ModalState } from './types'

export const initialState: ModalState = {
current: null,
stash: [],
}

const slice = createSlice({
name: 'modal',
initialState,
reducers: {
/**
* Show a new modal
*/
launch(state, action: PayloadAction<Modal>) {
if (state.current) {
state.stash.push(state.current)
}
state.current = action.payload
},

/**
* Close the current modal
*/
close(state) {
state.current = state.stash.pop() || null
},

/**
* Hide the current modal (with the intention of showing in again later)
*
* The semantics is the same as with git stash.
*/
stash(state) {
if (!state.current) {
throw new Error("You can't call hideModal if no model is shown!")
}
state.stash.push(state.current)
state.current = null
},

/**
* Show the previously hidden modal again.
*
* The semantics is the same as with `git stash pop`.
*/
stashPop(state) {
if (state.current) {
throw new Error("You can't call showModal when a modal is already visible!")
}
const latest = state.stash.pop()
if (!latest) return
state.current = latest
},
},
})

export const { actions: modalActions } = slice

export default slice.reducer
8 changes: 8 additions & 0 deletions src/app/components/Modal/slice/selectors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { createSelector } from '@reduxjs/toolkit'

import { RootState } from 'types'
import { initialState } from '.'

const selectSlice = (state: RootState) => state.modal || initialState

export const selectCurrentModal = createSelector([selectSlice], settings => settings.current)
24 changes: 24 additions & 0 deletions src/app/components/Modal/slice/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export interface Modal {
title: string
description: string
handleConfirm: () => void

/**
* Is this a dangerous operation?
*
* If marked as such, it will only be possible to execute it if the wallet is configured to run in dangerous mode.
*
* It also automatically implies a mandatory waiting time of 10 sec, unless specified otherwise.
*/
isDangerous: boolean

/**
* How long does the user have to wait before he can actually confirm the action?
*/
mustWaitSecs?: number
}

export interface ModalState {
current: Modal | null
stash: Modal[]
}
7 changes: 3 additions & 4 deletions src/app/components/SettingsButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,16 @@ import { SettingsDialog } from '../SettingsDialog'
import { selectIsSettingsDialogOpen } from '../SettingsDialog/slice/selectors'
import { useDispatch, useSelector } from 'react-redux'
import { settingsActions } from '../SettingsDialog/slice'
import { useModal } from '../Modal'
import { modalActions } from '../Modal/slice'

export const SettingsButton = () => {
const dispatch = useDispatch()
const layerVisibility = useSelector(selectIsSettingsDialogOpen)
const { t } = useTranslation()
const { showModal } = useModal()
const setLayerVisibility = (value: boolean) => dispatch(settingsActions.setOpen(value))
const closeSettings = () => {
setLayerVisibility(false)
showModal()
dispatch(settingsActions.setOpen(false))
dispatch(modalActions.stashPop())
}
return (
<>
Expand Down
7 changes: 4 additions & 3 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { AccountPage } from './pages/AccountPage'
import { CreateWalletPage } from './pages/CreateWalletPage'
import { HomePage } from './pages/HomePage'
import { OpenWalletPage } from './pages/OpenWalletPage'
import { ModalProvider } from './components/Modal'
import { ModalContainer } from './components/Modal'
import { useRouteRedirects } from './useRouteRedirects'

const AppMain = styled(Main)`
Expand All @@ -33,7 +33,7 @@ export function App() {
const size = useContext(ResponsiveContext)

return (
<ModalProvider>
<>
<Helmet
titleTemplate="%s - Oasis Wallet"
defaultTitle="Oasis Wallet"
Expand All @@ -57,6 +57,7 @@ export function App() {
</AppMain>
</Box>
</Box>
</ModalProvider>
<ModalContainer />
</>
)
}
5 changes: 3 additions & 2 deletions src/app/pages/AccountPage/Features/SendTransaction/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { TransactionStatus } from 'app/components/TransactionStatus'
import { useModal } from 'app/components/Modal'
import { modalActions } from '../../../../components/Modal/slice'
import { transactionActions } from 'app/state/transaction'
import { selectTransaction } from 'app/state/transaction/selectors'
import { selectValidators } from 'app/state/staking/selectors'
Expand All @@ -9,6 +9,7 @@ import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useDispatch, useSelector } from 'react-redux'
import { parseRoseStringToBaseUnitString } from 'app/lib/helpers'
import { Modal } from '../../../../components/Modal/slice/types'

export interface SendTransactionProps {
isAddressInWallet: boolean
Expand All @@ -21,7 +22,7 @@ export function SendTransaction(props: SendTransactionProps) {

const { t } = useTranslation()
const dispatch = useDispatch()
const { launchModal } = useModal()
const launchModal = (modal: Modal) => dispatch(modalActions.launch(modal))
const { error, success } = useSelector(selectTransaction)
const validators = useSelector(selectValidators)
const [recipient, setRecipient] = useState('')
Expand Down
8 changes: 7 additions & 1 deletion src/store/configureStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,13 @@ export function configureAppStore(state?: Partial<RootState>) {

const store = configureStore({
reducer: createReducer(),
middleware: getDefaultMiddleware => getDefaultMiddleware().concat(middlewares),
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: {
ignoredActionPaths: ['payload.handleConfirm'],
ignoredPaths: ['modal.current.handleConfirm', 'modal.stash'],
},
}).concat(middlewares),
devTools:
/* istanbul ignore next line */
process.env.NODE_ENV !== 'production',
Expand Down
2 changes: 2 additions & 0 deletions src/store/reducers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import stakingReducer from 'app/state/staking'
import transactionReducer from 'app/state/transaction'
import walletReducer from 'app/state/wallet'
import themeReducer from 'styles/theme/slice'
import modalReducer from 'app/components/Modal/slice'
import settingReducer from 'app/components/SettingsDialog/slice'

export function createReducer() {
Expand All @@ -25,6 +26,7 @@ export function createReducer() {
theme: themeReducer,
transaction: transactionReducer,
wallet: walletReducer,
modal: modalReducer,
settings: settingReducer,
})

Expand Down
Loading

0 comments on commit 39da44e

Please sign in to comment.