From cc5993850a73627fb4b1471eeb035d8a32cfae38 Mon Sep 17 00:00:00 2001 From: schmanu Date: Fri, 30 Aug 2024 18:09:37 +0200 Subject: [PATCH 1/5] feat: redesign of multiaccounts and context menu for multiaccounts - 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 | 10 +- .../MultiAccountContextMenu.tsx | 107 ++++++++++++++++++ .../sidebar/SafeListContextMenu/index.tsx | 2 +- .../welcome/MyAccounts/MultiAccountItem.tsx | 41 +++++-- .../welcome/MyAccounts/SubAccountItem.tsx | 1 - .../welcome/MyAccounts/styles.module.css | 44 ++----- .../components/CreateSafeOnNewChain/index.tsx | 2 +- 7 files changed, 155 insertions(+), 52 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..97d3d60b31 100644 --- a/src/components/address-book/EntryDialog/index.tsx +++ b/src/components/address-book/EntryDialog/index.tsx @@ -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) { + chainIds?.forEach((chainId) => dispatch(upsertAddressBookEntry({ ...data, chainId }))) + } 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..11704fdf62 --- /dev/null +++ b/src/components/sidebar/SafeListContextMenu/MultiAccountContextMenu.tsx @@ -0,0 +1,107 @@ +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 trackingLabel = + router.pathname === AppRoutes.welcome.accounts ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + + const handleOpenContextMenu = (e: MouseEvent) => { + e.stopPropagation() + setAnchorEl(e.currentTarget) + } + + const handleCloseContextMenu = (event: MouseEvent) => { + event.stopPropagation() + setAnchorEl(undefined) + } + + const handleOpenModal = + (type: keyof typeof open, event: typeof OVERVIEW_EVENTS.SIDEBAR_RENAME | typeof OVERVIEW_EVENTS.SIDEBAR_RENAME) => + (e: MouseEvent) => { + 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..294edf8e52 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 @@ -46,6 +48,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 +87,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 +103,6 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: - {name && ( @@ -111,7 +113,6 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: {shortenAddress(address)} - {totalFiatValue !== undefined ? ( @@ -119,12 +120,28 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: )} - - + + Multichain account on: + {safes.map((safeItem) => ( + + + + ))} + + } + arrow + > + + + + + - - + + {safes.map((safeItem) => ( {!isWatchlist && ( <> - - + + -
) } diff --git a/src/components/welcome/MyAccounts/styles.module.css b/src/components/welcome/MyAccounts/styles.module.css index e43b0d0b0f..f9808061f2 100644 --- a/src/components/welcome/MyAccounts/styles.module.css +++ b/src/components/welcome/MyAccounts/styles.module.css @@ -44,6 +44,12 @@ background-color: var(--color-background-light) !important; } +.currentListItem.multiListItem { + border-left-width: 1px; + 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 +60,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 7a8b737713..dea37eca44 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -87,7 +87,7 @@ export const CreateSafeOnNewChain = ({ const submitDisabled = !!safeCreationDataError return ( - + e.stopPropagation()}>
Add another network From 1c59e546917a98ff9d6028f8c7e2991ad3b10eb9 Mon Sep 17 00:00:00 2001 From: schmanu Date: Wed, 11 Sep 2024 14:03:16 +0200 Subject: [PATCH 2/5] fix: introduce upsertMUltichainAddressBookEntry reducer --- .../address-book/EntryDialog/index.tsx | 4 +-- src/store/__tests__/addressBookSlice.test.ts | 34 +++++++++++++++++++ src/store/addressBookSlice.ts | 17 +++++++++- 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/src/components/address-book/EntryDialog/index.tsx b/src/components/address-book/EntryDialog/index.tsx index 97d3d60b31..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 = { @@ -42,7 +42,7 @@ function EntryDialog({ const submitCallback = handleSubmit((data: AddressEntry) => { if (chainIds) { - chainIds?.forEach((chainId) => dispatch(upsertAddressBookEntry({ ...data, chainId }))) + dispatch(upsertMultichainAddressBookEntry({ ...data, chainIds })) } else { dispatch(upsertAddressBookEntry({ ...data, chainId: currentChainId })) } 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] From 8437fbcebf7c7d75f72ba252cb2c3015de0e3dbd Mon Sep 17 00:00:00 2001 From: schmanu Date: Wed, 11 Sep 2024 14:40:20 +0200 Subject: [PATCH 3/5] refactor: extract component --- .../welcome/MyAccounts/MultiAccountItem.tsx | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/src/components/welcome/MyAccounts/MultiAccountItem.tsx b/src/components/welcome/MyAccounts/MultiAccountItem.tsx index 294edf8e52..259b6ded97 100644 --- a/src/components/welcome/MyAccounts/MultiAccountItem.tsx +++ b/src/components/welcome/MyAccounts/MultiAccountItem.tsx @@ -39,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) @@ -120,23 +142,7 @@ const MultiAccountItem = ({ onLinkClick, multiSafeAccountItem, safeOverviews }: )} - - Multichain account on: - {safes.map((safeItem) => ( - - - - ))} - - } - arrow - > - - - - + From d15bdb6d9124e31298ba6db9e1d79af4cb2002d3 Mon Sep 17 00:00:00 2001 From: schmanu Date: Thu, 12 Sep 2024 16:14:24 +0200 Subject: [PATCH 4/5] fix: show error if no chains available --- .../components/CreateSafeOnNewChain/index.tsx | 76 ++++++++++--------- 1 file changed, 39 insertions(+), 37 deletions(-) diff --git a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx index 0051edbd05..5503467db6 100644 --- a/src/features/multichain/components/CreateSafeOnNewChain/index.tsx +++ b/src/features/multichain/components/CreateSafeOnNewChain/index.tsx @@ -104,49 +104,51 @@ const ReplaySafeDialog = ({ const submitDisabled = !!safeCreationDataError || safeCreationDataLoading || !formMethods.formState.isValid + const noChainsAvailable = !chain && safeCreationData && replayableChains && replayableChains.length === 0 + return ( e.stopPropagation()}> Add another network - {safeCreationDataError ? ( - - Could not determine the Safe creation parameters. - - ) : ( - - - 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. - - {safeCreationDataLoading ? ( - - - Loading Safe data - - ) : ( - <> - - - {chain ? ( - - ) : ( - - )} - - )} - - {creationError && ( - - The Safe could not be created with the same address. - - )} - - - )} + ) : noChainsAvailable ? ( + This Safe cannot be replayed on any chains. + ) : ( + <> + + + {chain ? ( + + ) : ( + + )} + + )} + + {creationError && ( + + The Safe could not be created with the same address. + + )} + +