From 6555c956b30660a377dbf8c8bf6237c2123fddb3 Mon Sep 17 00:00:00 2001 From: Manuel Gellfart Date: Tue, 17 Sep 2024 18:01:46 +0200 Subject: [PATCH] [Multichain] feat: redesign of multiaccounts and context menu for multiaccounts (#4125) - Redesigns how sub-items are displayed under multi-accounts. - Adds context menu for multi accounts offering renaming and adding a new network - Some UX fixes around the context menu --- .../address-book/EntryDialog/index.tsx | 12 +- .../MultiAccountContextMenu.tsx | 106 ++++++++++++++++++ .../sidebar/SafeListContextMenu/index.tsx | 2 +- .../welcome/MyAccounts/MultiAccountItem.tsx | 47 ++++++-- .../welcome/MyAccounts/SubAccountItem.tsx | 1 - .../welcome/MyAccounts/styles.module.css | 43 ++----- .../components/CreateSafeOnNewChain/index.tsx | 82 +++++++------- src/store/__tests__/addressBookSlice.test.ts | 34 ++++++ src/store/addressBookSlice.ts | 17 ++- 9 files changed, 253 insertions(+), 91 deletions(-) create mode 100644 src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx diff --git a/src/components/address-book/EntryDialog/index.tsx b/src/components/address-book/EntryDialog/index.tsx index 4faa8c199b..41616de15d 100644 --- a/src/components/address-book/EntryDialog/index.tsx +++ b/src/components/address-book/EntryDialog/index.tsx @@ -7,7 +7,7 @@ import ModalDialog from '@/components/common/ModalDialog' import NameInput from '@/components/common/NameInput' import useChainId from '@/hooks/useChainId' import { useAppDispatch } from '@/store' -import { upsertAddressBookEntry } from '@/store/addressBookSlice' +import { upsertAddressBookEntry, upsertMultichainAddressBookEntry } from '@/store/addressBookSlice' import madProps from '@/utils/mad-props' export type AddressEntry = { @@ -22,13 +22,13 @@ function EntryDialog({ address: '', }, disableAddressInput = false, - chainId, + chainIds, currentChainId, }: { handleClose: () => void defaultValues?: AddressEntry disableAddressInput?: boolean - chainId?: string + chainIds?: string[] currentChainId: string }): ReactElement { const dispatch = useAppDispatch() @@ -41,7 +41,11 @@ function EntryDialog({ const { handleSubmit, formState } = methods const submitCallback = handleSubmit((data: AddressEntry) => { - dispatch(upsertAddressBookEntry({ ...data, chainId: chainId || currentChainId })) + if (chainIds) { + dispatch(upsertMultichainAddressBookEntry({ ...data, chainIds })) + } else { + dispatch(upsertAddressBookEntry({ ...data, chainId: currentChainId })) + } handleClose() }) diff --git a/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx b/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx new file mode 100644 index 0000000000..a5a796f752 --- /dev/null +++ b/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx @@ -0,0 +1,106 @@ +import type { MouseEvent } from 'react' +import { useState, type ReactElement } from 'react' +import ListItemIcon from '@mui/material/ListItemIcon' +import IconButton from '@mui/material/IconButton' +import MoreVertIcon from '@mui/icons-material/MoreVert' +import MenuItem from '@mui/material/MenuItem' +import ListItemText from '@mui/material/ListItemText' + +import EntryDialog from '@/components/address-book/EntryDialog' +import EditIcon from '@/public/images/common/edit.svg' +import PlusIcon from '@/public/images/common/plus.svg' +import ContextMenu from '@/components/common/ContextMenu' +import { trackEvent, OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { SvgIcon } from '@mui/material' +import { AppRoutes } from '@/config/routes' +import router from 'next/router' +import { CreateSafeOnNewChain } from '@/features/multichain/components/CreateSafeOnNewChain' + +enum ModalType { + RENAME = 'rename', + ADD_CHAIN = 'add_chain', +} + +const defaultOpen = { [ModalType.RENAME]: false, [ModalType.ADD_CHAIN]: false } + +const MultiAccountContextMenu = ({ + name, + address, + chainIds, +}: { + name: string + address: string + chainIds: string[] +}): ReactElement => { + const [anchorEl, setAnchorEl] = useState() + const [open, setOpen] = useState(defaultOpen) + + const handleOpenContextMenu = (e: MouseEvent) => { + e.stopPropagation() + setAnchorEl(e.currentTarget) + } + + const handleCloseContextMenu = (event: MouseEvent) => { + event.stopPropagation() + setAnchorEl(undefined) + } + + const handleOpenModal = + (type: ModalType, event: typeof OVERVIEW_EVENTS.SIDEBAR_RENAME | typeof OVERVIEW_EVENTS.ADD_NEW_NETWORK) => + (e: MouseEvent) => { + const trackingLabel = + router.pathname === AppRoutes.welcome.accounts ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + handleCloseContextMenu(e) + setOpen((prev) => ({ ...prev, [type]: true })) + + trackEvent({ ...event, label: trackingLabel }) + } + + const handleCloseModal = () => { + setOpen(defaultOpen) + } + + return ( + <> + + ({ color: palette.border.main })} /> + + + + + + + Rename + + + + + + + Add another network + + + + {open[ModalType.RENAME] && ( + + )} + + {open[ModalType.ADD_CHAIN] && ( + + )} + + ) +} + +export default MultiAccountContextMenu diff --git a/src/components/sidebar/SafeListContextMenu/index.tsx b/src/components/sidebar/SafeListContextMenu/index.tsx index 2f1761c1ed..ec9cfa067d 100644 --- a/src/components/sidebar/SafeListContextMenu/index.tsx +++ b/src/components/sidebar/SafeListContextMenu/index.tsx @@ -108,7 +108,7 @@ const SafeListContextMenu = ({ )} diff --git a/src/components/welcome/MyAccounts/MultiAccountItem.tsx b/src/components/welcome/MyAccounts/MultiAccountItem.tsx index f13a19ea59..259b6ded97 100644 --- a/src/components/welcome/MyAccounts/MultiAccountItem.tsx +++ b/src/components/welcome/MyAccounts/MultiAccountItem.tsx @@ -10,6 +10,7 @@ import { AccordionDetails, AccordionSummary, Divider, + Tooltip, } from '@mui/material' import SafeIcon from '@/components/common/SafeIcon' import { OVERVIEW_EVENTS, OVERVIEW_LABELS, trackEvent } from '@/services/analytics' @@ -25,11 +26,12 @@ import FiatValue from '@/components/common/FiatValue' import { type MultiChainSafeItem } from './useAllSafesGrouped' import MultiChainIcon from '@/public/images/sidebar/multichain-account.svg' import { shortenAddress } from '@/utils/formatters' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' import { type SafeItem } from './useAllSafes' import SubAccountItem from './SubAccountItem' import { getSharedSetup } from './utils/multiChainSafe' import { AddNetworkButton } from './AddNetworkButton' +import ChainIndicator from '@/components/common/ChainIndicator' +import MultiAccountContextMenu from '@/components/sidebar/SafeListContextMenu/MultiAccountContextMenu' type MultiAccountItemProps = { multiSafeAccountItem: MultiChainSafeItem @@ -37,6 +39,28 @@ type MultiAccountItemProps = { onLinkClick?: () => void } +const MultichainIndicator = ({ safes }: { safes: SafeItem[] }) => { + return ( + + Multichain account on: + {safes.map((safeItem) => ( + + + + ))} + + } + arrow + > + + + + + ) +} + const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: MultiAccountItemProps) => { const { address, safes } = multiSafeAccountItem const undeployedSafes = useAppSelector(selectUndeployedSafes) @@ -46,6 +70,8 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: const isWelcomePage = router.pathname === AppRoutes.welcome.accounts const [expanded, setExpanded] = useState(isCurrentSafe) + const deployedChains = useMemo(() => safes.map((safe) => safe.chainId), [safes]) + const isWatchlist = useMemo( () => multiSafeAccountItem.safes.every((safe) => safe.isWatchlist), [multiSafeAccountItem.safes], @@ -83,16 +109,15 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: } sx={{ pl: 0, - '& .MuiAccordionSummary-content': { m: 0 }, + '& .MuiAccordionSummary-content': { m: 0, alignItems: 'center' }, '&.Mui-expanded': { backgroundColor: 'transparent !important' }, }} > @@ -100,7 +125,6 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: - {name && ( @@ -111,7 +135,6 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: {shortenAddress(address)} - {totalFiatValue !== undefined ? ( @@ -119,12 +142,12 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: )} - - + + - - + + {safes.map((safeItem) => ( {!isWatchlist && ( <> - - + + -
) } diff --git a/src/components/welcome/MyAccounts/styles.module.css b/src/components/welcome/MyAccounts/styles.module.css index e43b0d0b0f..76249917d8 100644 --- a/src/components/welcome/MyAccounts/styles.module.css +++ b/src/components/welcome/MyAccounts/styles.module.css @@ -44,6 +44,11 @@ background-color: var(--color-background-light) !important; } +.currentListItem.multiListItem { + border: 1px solid var(--color-border-light); + background-color: none; +} + .listItem :global .MuiAccordion-root, .listItem :global .MuiAccordion-root:hover > .MuiAccordionSummary-root { background-color: transparent; @@ -54,48 +59,18 @@ } .listItem.subItem { - border: none; - margin-bottom: 0px; - border-radius: 0px; -} - -.subItem:before { - content: ''; - display: block; - width: 8px; - height: 1px; - background: var(--color-border-light); - left: 0; - top: 50%; - position: absolute; -} - -.subItem.currentListItem { - border: none; -} - -.subItem.currentListItem:before { - background: var(--color-secondary-light); - height: 1px; + margin-bottom: 8px; } .subItem .borderLeft { top: 0; bottom: 0; position: absolute; - border-left: 1px solid var(--color-border-light); + border-radius: 6px; + border: 1px solid var(--color-border-light); } .subItem.currentListItem .borderLeft { - border-left: 1px solid var(--color-secondary-light); -} - -.subItem:last-child { - border-left: none; -} - -.subItem:last-child .borderLeft { - top: 0%; - bottom: 50%; + border-left: 4px solid var(--color-secondary-light); } .listItem > :first-child { diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index 30ffca4671..32d6781be0 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -109,50 +109,56 @@ const ReplaySafeDialog = ({ const submitDisabled = isUnsupportedSafeCreationVersion || !!safeCreationDataError || safeCreationDataLoading || !formState.isValid + const noChainsAvailable = !chain && safeCreationData && replayableChains && replayableChains.length === 0 + return ( - + e.stopPropagation()}>
Add another network - {safeCreationDataError ? ( - - Could not determine the Safe creation parameters. - - ) : safeCreationDataLoading ? ( - - - Loading Safe data - - ) : isUnsupportedSafeCreationVersion ? ( - - This account was created from an outdated mastercopy. Adding another network is not possible. - - ) : ( - - - This action re-deploys a Safe to another network with the same address. - - The Safe will use the initial setup of the copied Safe. Any changes to owners, threshold, modules or - the Safe's version will not be reflected in the copy. + + + This action re-deploys a Safe to another network with the same address. + + The Safe will use the initial setup of the copied Safe. Any changes to owners, threshold, modules or the + Safe's version will not be reflected in the copy. + + + {safeCreationDataLoading ? ( + + + Loading Safe data + + ) : safeCreationDataError ? ( + + Could not determine the Safe creation parameters. - - - - {chain ? ( - - ) : ( - - )} - - {creationError && ( - - The Safe could not be created with the same address. - - )} - - - )} + ) : isUnsupportedSafeCreationVersion ? ( + + This account was created from an outdated mastercopy. Adding another network is not possible. + + ) : noChainsAvailable ? ( + This Safe cannot be replayed on any chains. + ) : ( + <> + + + {chain ? ( + + ) : ( + + )} + + )} + + {creationError && ( + + The Safe could not be created with the same address. + + )} + + diff --git a/src/store/__tests__/addressBookSlice.test.ts b/src/store/__tests__/addressBookSlice.test.ts index d72cc69d2b..ca4e11a2d9 100644 --- a/src/store/__tests__/addressBookSlice.test.ts +++ b/src/store/__tests__/addressBookSlice.test.ts @@ -1,9 +1,11 @@ +import { faker } from '@faker-js/faker' import { addressBookSlice, setAddressBook, upsertAddressBookEntry, removeAddressBookEntry, selectAddressBookByChain, + upsertMultichainAddressBookEntry, } from '../addressBookSlice' const initialState = { @@ -59,6 +61,38 @@ describe('addressBookSlice', () => { }) }) + it('should insert an multichain entry in the address book', () => { + const address = faker.finance.ethereumAddress() + const state = addressBookSlice.reducer( + initialState, + upsertMultichainAddressBookEntry({ + chainIds: ['1', '10', '100', '137'], + address, + name: 'Max', + }), + ) + expect(state).toEqual({ + '1': { '0x0': 'Alice', '0x1': 'Bob', [address]: 'Max' }, + '4': { '0x0': 'Charlie', '0x1': 'Dave' }, + '10': { [address]: 'Max' }, + '100': { [address]: 'Max' }, + '137': { [address]: 'Max' }, + }) + }) + + it('should ignore empty names for multichain entries', () => { + const address = faker.finance.ethereumAddress() + const state = addressBookSlice.reducer( + initialState, + upsertMultichainAddressBookEntry({ + chainIds: ['1', '10', '100', '137'], + address, + name: '', + }), + ) + expect(state).toEqual(initialState) + }) + it('should remove an entry from the address book', () => { const stateB = addressBookSlice.reducer( initialState, diff --git a/src/store/addressBookSlice.ts b/src/store/addressBookSlice.ts index 0dc2081e94..99dc98a981 100644 --- a/src/store/addressBookSlice.ts +++ b/src/store/addressBookSlice.ts @@ -33,6 +33,20 @@ export const addressBookSlice = createSlice({ state[chainId][address] = name }, + upsertMultichainAddressBookEntry: ( + state, + action: PayloadAction<{ chainIds: string[]; address: string; name: string }>, + ) => { + const { chainIds, address, name } = action.payload + if (name.trim() === '') { + return + } + chainIds.forEach((chainId) => { + if (!state[chainId]) state[chainId] = {} + state[chainId][address] = name + }) + }, + removeAddressBookEntry: (state, action: PayloadAction<{ chainId: string; address: string }>) => { const { chainId, address } = action.payload if (!state[chainId]) return state @@ -43,7 +57,8 @@ export const addressBookSlice = createSlice({ }, }) -export const { setAddressBook, upsertAddressBookEntry, removeAddressBookEntry } = addressBookSlice.actions +export const { setAddressBook, upsertAddressBookEntry, upsertMultichainAddressBookEntry, removeAddressBookEntry } = + addressBookSlice.actions export const selectAllAddressBooks = (state: RootState): AddressBookState => { return state[addressBookSlice.name]