From 39da44e8c37b864caafd83c888e38a7ac9f17c45 Mon Sep 17 00:00:00 2001 From: Csillag Kristof Date: Thu, 20 Oct 2022 13:24:11 +0200 Subject: [PATCH] Refactor Modal so that - The state is handled in Redux - No more ModalProvider - pop-up in pop-up is now supported (recursively) --- .../AddEscrowForm/__tests__/index.test.tsx | 7 +- src/app/components/AddEscrowForm/index.tsx | 5 +- src/app/components/Modal/index.tsx | 123 +++--------------- src/app/components/Modal/slice/index.ts | 63 +++++++++ src/app/components/Modal/slice/selectors.ts | 8 ++ src/app/components/Modal/slice/types.ts | 24 ++++ src/app/components/SettingsButton/index.tsx | 7 +- src/app/index.tsx | 7 +- .../Features/SendTransaction/index.tsx | 5 +- src/store/configureStore.ts | 8 +- src/store/reducers.ts | 2 + src/types/RootState.ts | 2 + 12 files changed, 139 insertions(+), 122 deletions(-) create mode 100644 src/app/components/Modal/slice/index.ts create mode 100644 src/app/components/Modal/slice/selectors.ts create mode 100644 src/app/components/Modal/slice/types.ts diff --git a/src/app/components/AddEscrowForm/__tests__/index.test.tsx b/src/app/components/AddEscrowForm/__tests__/index.test.tsx index fbe3955cec..8c72486ee5 100644 --- a/src/app/components/AddEscrowForm/__tests__/index.test.tsx +++ b/src/app/components/AddEscrowForm/__tests__/index.test.tsx @@ -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' @@ -12,9 +12,8 @@ import { AddEscrowForm } from '..' const renderComponent = (store: any, address: string, validatorStatus: Validator['status']) => render( - - - + + , ) diff --git a/src/app/components/AddEscrowForm/index.tsx b/src/app/components/AddEscrowForm/index.tsx index 54447266dc..b9738bf736 100644 --- a/src/app/components/AddEscrowForm/index.tsx +++ b/src/app/components/AddEscrowForm/index.tsx @@ -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' @@ -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) diff --git a/src/app/components/Modal/index.tsx b/src/app/components/Modal/index.tsx index e30ee22540..4182a9915f 100644 --- a/src/app/components/Modal/index.tsx +++ b/src/app/components/Modal/index.tsx @@ -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' @@ -7,70 +7,22 @@ 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({} 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 @@ -78,10 +30,9 @@ const ModalContainer = ({ modal, closeModal, hideModal }: ModalContainerProps) = ? 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(() => { @@ -101,6 +52,7 @@ const ModalContainer = ({ modal, closeModal, hideModal }: ModalContainerProps) = } }, [waitingTime]) + if (!modal) return null return ( @@ -148,45 +100,4 @@ const ModalContainer = ({ modal, closeModal, hideModal }: ModalContainerProps) = ) } -const ModalProvider = (props: ModalProviderProps) => { - const [modal, setModal] = useState(null) - const [hiddenModal, setHiddenModal] = useState(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 ( - - {props.children} - {modal && } - - ) -} - -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 } diff --git a/src/app/components/Modal/slice/index.ts b/src/app/components/Modal/slice/index.ts new file mode 100644 index 0000000000..5305be0f5f --- /dev/null +++ b/src/app/components/Modal/slice/index.ts @@ -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) { + 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 diff --git a/src/app/components/Modal/slice/selectors.ts b/src/app/components/Modal/slice/selectors.ts new file mode 100644 index 0000000000..46aa32cb33 --- /dev/null +++ b/src/app/components/Modal/slice/selectors.ts @@ -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) diff --git a/src/app/components/Modal/slice/types.ts b/src/app/components/Modal/slice/types.ts new file mode 100644 index 0000000000..fe56e5c438 --- /dev/null +++ b/src/app/components/Modal/slice/types.ts @@ -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[] +} diff --git a/src/app/components/SettingsButton/index.tsx b/src/app/components/SettingsButton/index.tsx index b26c65180b..08defcd70e 100644 --- a/src/app/components/SettingsButton/index.tsx +++ b/src/app/components/SettingsButton/index.tsx @@ -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 ( <> diff --git a/src/app/index.tsx b/src/app/index.tsx index 20fb36d776..62c0f6efff 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -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)` @@ -33,7 +33,7 @@ export function App() { const size = useContext(ResponsiveContext) return ( - + <> - + + ) } diff --git a/src/app/pages/AccountPage/Features/SendTransaction/index.tsx b/src/app/pages/AccountPage/Features/SendTransaction/index.tsx index 3a2b2d1a12..2ac2cc17c7 100644 --- a/src/app/pages/AccountPage/Features/SendTransaction/index.tsx +++ b/src/app/pages/AccountPage/Features/SendTransaction/index.tsx @@ -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' @@ -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 @@ -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('') diff --git a/src/store/configureStore.ts b/src/store/configureStore.ts index 88a5a710a0..ede5a0e2b0 100644 --- a/src/store/configureStore.ts +++ b/src/store/configureStore.ts @@ -24,7 +24,13 @@ export function configureAppStore(state?: Partial) { 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', diff --git a/src/store/reducers.ts b/src/store/reducers.ts index 9111fd363f..ccbd57890e 100644 --- a/src/store/reducers.ts +++ b/src/store/reducers.ts @@ -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() { @@ -25,6 +26,7 @@ export function createReducer() { theme: themeReducer, transaction: transactionReducer, wallet: walletReducer, + modal: modalReducer, settings: settingReducer, }) diff --git a/src/types/RootState.ts b/src/types/RootState.ts index 7c554adcb4..ed31727099 100644 --- a/src/types/RootState.ts +++ b/src/types/RootState.ts @@ -9,6 +9,7 @@ import { ImportAccountsState } from 'app/state/importaccounts/types' import { StakingState } from 'app/state/staking/types' import { FatalErrorState } from 'app/state/fatalerror/types' import { SettingsState } from '../app/components/SettingsDialog/slice/types' +import { ModalState } from '../app/components/Modal/slice/types' // [IMPORT NEW CONTAINERSTATE ABOVE] < Needed for generating containers seamlessly export interface RootState { @@ -21,6 +22,7 @@ export interface RootState { importAccounts: ImportAccountsState staking: StakingState fatalError: FatalErrorState + modal: ModalState settings: SettingsState // [INSERT NEW REDUCER KEY ABOVE] < Needed for generating containers seamlessly }