diff --git a/src/features/myAccounts/components/AccountListFilters/index.tsx b/src/features/myAccounts/components/AccountListFilters/index.tsx new file mode 100644 index 0000000000..c3a16466ad --- /dev/null +++ b/src/features/myAccounts/components/AccountListFilters/index.tsx @@ -0,0 +1,60 @@ +import { useAppDispatch, useAppSelector } from '@/store' +import { type OrderByOption, selectOrderByPreference, setOrderByPreference } from '@/store/orderByPreferenceSlice' +import debounce from 'lodash/debounce' +import { type Dispatch, type SetStateAction, useCallback } from 'react' +import OrderByButton from '@/features/myAccounts/components/OrderByButton' +import css from '@/features/myAccounts/styles.module.css' +import SearchIcon from '@/public/images/common/search.svg' +import { Box, InputAdornment, Paper, SvgIcon, TextField } from '@mui/material' + +const AccountListFilters = ({ setSearchQuery }: { setSearchQuery: Dispatch> }) => { + const dispatch = useAppDispatch() + const { orderBy } = useAppSelector(selectOrderByPreference) + + // eslint-disable-next-line react-hooks/exhaustive-deps + const handleSearch = useCallback(debounce(setSearchQuery, 300), []) + + const handleOrderByChange = (orderBy: OrderByOption) => { + dispatch(setOrderByPreference({ orderBy })) + } + + return ( + + + { + handleSearch(e.target.value) + }} + className={css.search} + InputProps={{ + startAdornment: ( + + + + ), + disableUnderline: true, + }} + fullWidth + size="small" + /> + + + + ) +} + +export default AccountListFilters diff --git a/src/features/myAccounts/components/AccountsHeader/index.tsx b/src/features/myAccounts/components/AccountsHeader/index.tsx new file mode 100644 index 0000000000..90d8cdde19 --- /dev/null +++ b/src/features/myAccounts/components/AccountsHeader/index.tsx @@ -0,0 +1,55 @@ +import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWalletButton' +import Track from '@/components/common/Track' +import { AppRoutes } from '@/config/routes' +import CreateButton from '@/features/myAccounts/components/CreateButton' +import css from '@/features/myAccounts/styles.module.css' +import useWallet from '@/hooks/wallets/useWallet' +import AddIcon from '@/public/images/common/add.svg' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import { Box, Button, Link, SvgIcon, Typography } from '@mui/material' +import classNames from 'classnames' +import { useRouter } from 'next/router' + +const AccountsHeader = ({ isSidebar, onLinkClick }: { isSidebar: boolean; onLinkClick?: () => void }) => { + const router = useRouter() + const wallet = useWallet() + + const isLoginPage = router.pathname === AppRoutes.welcome.accounts + const trackingLabel = isLoginPage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + + return ( + + + My accounts + + + + + + + + + {wallet ? ( + + + + ) : ( + + + + )} + + + ) +} + +export default AccountsHeader diff --git a/src/features/myAccounts/components/AccountsList/index.test.tsx b/src/features/myAccounts/components/AccountsList/index.test.tsx new file mode 100644 index 0000000000..45136cb90c --- /dev/null +++ b/src/features/myAccounts/components/AccountsList/index.test.tsx @@ -0,0 +1,113 @@ +import { OrderByOption } from '@/store/orderByPreferenceSlice' +import { safeItemBuilder } from '@/tests/builders/safeItem' +import { render } from '@/tests/test-utils' +import React from 'react' +import { screen } from '@testing-library/react' +import AccountsList from './index' +import FilteredSafes from '@/features/myAccounts/components/FilteredSafes' +import PinnedSafes from '@/features/myAccounts/components/PinnedSafes' +import AllSafes from '@/features/myAccounts/components/AllSafes' +import type { AllSafeItemsGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' + +// Mock child components to simplify tests, we just need to verify their rendering and props. +jest.mock('@/features/myAccounts/components/FilteredSafes', () => jest.fn(() =>
FilteredSafes Component
)) +jest.mock('@/features/myAccounts/components/PinnedSafes', () => jest.fn(() =>
PinnedSafes Component
)) +jest.mock('@/features/myAccounts/components/AllSafes', () => jest.fn(() =>
AllSafes Component
)) + +describe('AccountsList', () => { + const baseSafes: AllSafeItemsGrouped = { + allMultiChainSafes: [ + { name: 'MultiChainSafe1', address: '0xA', isPinned: false, lastVisited: 0, safes: [safeItemBuilder().build()] }, + { name: 'MultiChainSafe2', address: '0xB', isPinned: false, lastVisited: 1, safes: [safeItemBuilder().build()] }, + ], + allSingleSafes: [ + { name: 'SingleSafe1', address: '0xC', isPinned: true, chainId: '3', isReadOnly: false, lastVisited: 2 }, + ], + } + + afterEach(() => { + jest.clearAllMocks() + }) + + it('renders FilteredSafes when searchQuery is not empty', () => { + render(, { + initialReduxState: { orderByPreference: { orderBy: OrderByOption.NAME } }, + }) + + expect(screen.getByText('FilteredSafes Component')).toBeInTheDocument() + expect(screen.queryByText('PinnedSafes Component')).not.toBeInTheDocument() + expect(screen.queryByText('AllSafes Component')).not.toBeInTheDocument() + + // Check that FilteredSafes is called with the correct props + const filteredSafesMock = (FilteredSafes as jest.Mock).mock.calls[0][0] + expect(filteredSafesMock.searchQuery).toBe('Multi') + expect(filteredSafesMock.onLinkClick).toBeUndefined() + + // The combined allSafes array sorted by name + const expectedSortedSafes = [ + { name: 'MultiChainSafe1', address: '0xA', isPinned: false, lastVisited: 0, safes: expect.anything() }, + { name: 'MultiChainSafe2', address: '0xB', isPinned: false, lastVisited: 1, safes: expect.anything() }, + { name: 'SingleSafe1', address: '0xC', isPinned: true, chainId: '3', isReadOnly: false, lastVisited: 2 }, + ] + expect(filteredSafesMock.allSafes).toEqual(expectedSortedSafes) + }) + + it('renders PinnedSafes and AllSafes when searchQuery is empty', () => { + render(, { + initialReduxState: { orderByPreference: { orderBy: OrderByOption.NAME } }, + }) + + expect(screen.queryByText('FilteredSafes Component')).not.toBeInTheDocument() + expect(screen.getByText('PinnedSafes Component')).toBeInTheDocument() + expect(screen.getByText('AllSafes Component')).toBeInTheDocument() + + // Check that PinnedSafes and AllSafes received the correct props + const pinnedSafesMock = (PinnedSafes as jest.Mock).mock.calls[0][0] + const allSafesMock = (AllSafes as jest.Mock).mock.calls[0][0] + + // Sorted array as in the previous test + const expectedSortedSafes = [ + { name: 'MultiChainSafe1', address: '0xA', isPinned: false, lastVisited: 0, safes: expect.anything() }, + { name: 'MultiChainSafe2', address: '0xB', isPinned: false, lastVisited: 1, safes: expect.anything() }, + { name: 'SingleSafe1', address: '0xC', isPinned: true, chainId: '3', isReadOnly: false, lastVisited: 2 }, + ] + + expect(pinnedSafesMock.allSafes).toEqual(expectedSortedSafes) + expect(allSafesMock.allSafes).toEqual(expectedSortedSafes) + expect(allSafesMock.isSidebar).toBe(false) + }) + + it('sorts by lastVisited', () => { + render(, { + initialReduxState: { orderByPreference: { orderBy: OrderByOption.LAST_VISITED } }, + }) + + expect(screen.queryByText('FilteredSafes Component')).not.toBeInTheDocument() + expect(screen.getByText('PinnedSafes Component')).toBeInTheDocument() + expect(screen.getByText('AllSafes Component')).toBeInTheDocument() + + // Check that PinnedSafes and AllSafes received the correct props + const pinnedSafesMock = (PinnedSafes as jest.Mock).mock.calls[0][0] + const allSafesMock = (AllSafes as jest.Mock).mock.calls[0][0] + + const expectedSortedSafes = [ + { name: 'SingleSafe1', address: '0xC', isPinned: true, chainId: '3', isReadOnly: false, lastVisited: 2 }, + { name: 'MultiChainSafe2', address: '0xB', isPinned: false, lastVisited: 1, safes: expect.anything() }, + { name: 'MultiChainSafe1', address: '0xA', isPinned: false, lastVisited: 0, safes: expect.anything() }, + ] + + expect(pinnedSafesMock.allSafes).toEqual(expectedSortedSafes) + expect(allSafesMock.allSafes).toEqual(expectedSortedSafes) + }) + + it('passes onLinkClick prop down to children', () => { + const onLinkClickFn = jest.fn() + + render() + + const pinnedSafesMock = (PinnedSafes as jest.Mock).mock.calls[0][0] + const allSafesMock = (AllSafes as jest.Mock).mock.calls[0][0] + expect(pinnedSafesMock.onLinkClick).toBe(onLinkClickFn) + expect(allSafesMock.onLinkClick).toBe(onLinkClickFn) + }) +}) diff --git a/src/features/myAccounts/components/AccountsList/index.tsx b/src/features/myAccounts/components/AccountsList/index.tsx new file mode 100644 index 0000000000..a50b09d9a4 --- /dev/null +++ b/src/features/myAccounts/components/AccountsList/index.tsx @@ -0,0 +1,41 @@ +import FilteredSafes from '@/features/myAccounts/components/FilteredSafes' +import PinnedSafes from '@/features/myAccounts/components/PinnedSafes' +import type { AllSafeItems, AllSafeItemsGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import AllSafes from '@/features/myAccounts/components/AllSafes' +import { getComparator } from '@/features/myAccounts/utils/utils' +import { useAppSelector } from '@/store' +import { selectOrderByPreference } from '@/store/orderByPreferenceSlice' +import { useMemo } from 'react' + +const AccountsList = ({ + searchQuery, + safes, + onLinkClick, + isSidebar, +}: { + searchQuery: string + safes: AllSafeItemsGrouped + onLinkClick?: () => void + isSidebar: boolean +}) => { + const { orderBy } = useAppSelector(selectOrderByPreference) + const sortComparator = getComparator(orderBy) + + const allSafes = useMemo( + () => [...(safes.allMultiChainSafes ?? []), ...(safes.allSingleSafes ?? [])].sort(sortComparator), + [safes.allMultiChainSafes, safes.allSingleSafes, sortComparator], + ) + + if (searchQuery) { + return + } + + return ( + <> + + + + ) +} + +export default AccountsList diff --git a/src/features/myAccounts/components/AllSafes/index.tsx b/src/features/myAccounts/components/AllSafes/index.tsx new file mode 100644 index 0000000000..d3a6862b3b --- /dev/null +++ b/src/features/myAccounts/components/AllSafes/index.tsx @@ -0,0 +1,83 @@ +import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWalletButton' +import Track from '@/components/common/Track' +import { AppRoutes } from '@/config/routes' +import SafesList from '@/features/myAccounts/components/SafesList' +import type { AllSafeItems } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import css from '@/features/myAccounts/styles.module.css' +import useWallet from '@/hooks/wallets/useWallet' +import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' +import { Accordion, AccordionDetails, AccordionSummary, Box, Typography } from '@mui/material' +import { useRouter } from 'next/router' + +const AllSafes = ({ + allSafes, + onLinkClick, + isSidebar, +}: { + allSafes: AllSafeItems + onLinkClick?: () => void + isSidebar: boolean +}) => { + const wallet = useWallet() + const router = useRouter() + + const isLoginPage = router.pathname === AppRoutes.welcome.accounts + const trackingLabel = isLoginPage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + + return ( + + } + sx={{ + padding: 0, + '& .MuiAccordionSummary-content': { margin: '0 !important', mb: 1, flexGrow: 0 }, + }} + > +
+ + Accounts + {allSafes && allSafes.length > 0 && ( + + {' '} + ({allSafes.length}) + + )} + +
+
+ + {allSafes.length > 0 ? ( + + + + ) : ( + + {!wallet ? ( + <> + Connect a wallet to view your Safe Accounts or to create a new one + + + + + ) : ( + "You don't have any safes yet" + )} + + )} + +
+ ) +} + +export default AllSafes diff --git a/src/features/myAccounts/components/FilteredSafes/index.test.tsx b/src/features/myAccounts/components/FilteredSafes/index.test.tsx new file mode 100644 index 0000000000..36bab0ca9e --- /dev/null +++ b/src/features/myAccounts/components/FilteredSafes/index.test.tsx @@ -0,0 +1,85 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import FilteredSafes from './index' +import SafesList from '@/features/myAccounts/components/SafesList' +import type { AllSafeItems } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import * as safesSearch from '@/features/myAccounts/hooks/useSafesSearch' + +jest.mock('@/features/myAccounts/components/SafesList', () => jest.fn(() =>
)) + +describe('FilteredSafes', () => { + beforeEach(() => { + jest.spyOn(safesSearch, 'useSafesSearch').mockReturnValue([]) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + it('displays the correct heading when no results are found', () => { + const allSafes: AllSafeItems = [] + + render() + + expect(screen.getByText('Found 0 results')).toBeInTheDocument() + // SafesList should be rendered with empty array + const safesListProps = (SafesList as jest.Mock).mock.calls[0][0] + expect(safesListProps.safes).toHaveLength(0) + }) + + it('displays the correct heading when one result is found', () => { + const oneSafe = [ + { address: '0x1', name: 'Safe1', isPinned: false, chainId: '1', isReadOnly: false, lastVisited: 0 }, + ] + jest.spyOn(safesSearch, 'useSafesSearch').mockReturnValue(oneSafe) + + const allSafes: AllSafeItems = oneSafe + + render() + + // With one result, should say "Found 1 result" (singular) + expect(screen.getByText('Found 1 result')).toBeInTheDocument() + + const safesListProps = (SafesList as jest.Mock).mock.calls[0][0] + expect(safesListProps.safes).toEqual(oneSafe) + }) + + it('displays the correct heading when multiple results are found', () => { + const multiSafes = [ + { name: 'SafeA', address: '0xA', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0 }, + { name: 'SafeB', address: '0xB', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0 }, + ] + jest.spyOn(safesSearch, 'useSafesSearch').mockReturnValue(multiSafes) + + const allSafes: AllSafeItems = multiSafes + + render() + + // With two results, should say "Found 2 results" (plural) + expect(screen.getByText('Found 2 results')).toBeInTheDocument() + + const safesListProps = (SafesList as jest.Mock).mock.calls[0][0] + expect(safesListProps.safes).toEqual(multiSafes) + }) + + it('passes onLinkClick down to SafesList', () => { + const safes = [{ name: 'Safe1', address: '0x1', chainId: '1', isPinned: false, isReadOnly: false, lastVisited: 0 }] + jest.spyOn(safesSearch, 'useSafesSearch').mockReturnValue(safes) + const allSafes: AllSafeItems = safes + const onLinkClickMock = jest.fn() + + render() + + const safesListProps = (SafesList as jest.Mock).mock.calls[0][0] + expect(safesListProps.onLinkClick).toBe(onLinkClickMock) + }) + + it('sets useTransitions to false in SafesList', () => { + // Just verify that we are passing useTransitions={false} + jest.spyOn(safesSearch, 'useSafesSearch').mockReturnValue([]) + render() + + const safesListProps = (SafesList as jest.Mock).mock.calls[0][0] + expect(safesListProps.useTransitions).toBe(false) + }) +}) diff --git a/src/features/myAccounts/components/FilteredSafes/index.tsx b/src/features/myAccounts/components/FilteredSafes/index.tsx new file mode 100644 index 0000000000..13579cbca6 --- /dev/null +++ b/src/features/myAccounts/components/FilteredSafes/index.tsx @@ -0,0 +1,30 @@ +import SafesList from '@/features/myAccounts/components/SafesList' +import type { AllSafeItems } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import { useSafesSearch } from '@/features/myAccounts/hooks/useSafesSearch' +import { maybePlural } from '@/utils/formatters' +import { Box, Typography } from '@mui/material' + +const FilteredSafes = ({ + searchQuery, + allSafes, + onLinkClick, +}: { + searchQuery: string + allSafes: AllSafeItems + onLinkClick?: () => void +}) => { + const filteredSafes = useSafesSearch(allSafes ?? [], searchQuery) + + return ( + <> + + Found {filteredSafes.length} result{maybePlural(filteredSafes)} + + + + + + ) +} + +export default FilteredSafes diff --git a/src/features/myAccounts/components/PinnedSafes/index.test.tsx b/src/features/myAccounts/components/PinnedSafes/index.test.tsx new file mode 100644 index 0000000000..9ebc890e41 --- /dev/null +++ b/src/features/myAccounts/components/PinnedSafes/index.test.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import type { AllSafeItems } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import PinnedSafes from './index' +import SafesList from '@/features/myAccounts/components/SafesList' + +// Mock the SafesList component to ensure we can test the props passed to it +jest.mock('@/features/myAccounts/components/SafesList', () => + jest.fn(() =>
SafesList Component
), +) + +describe('PinnedSafes', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('renders the "Pinned" header', () => { + render() + expect(screen.getByText('Pinned')).toBeInTheDocument() + }) + + it('renders SafesList when there are pinned safes', () => { + const pinnedSafes: AllSafeItems = [ + { name: 'PinnedSafe1', address: '0x1', isPinned: true, chainId: '1', isReadOnly: false, lastVisited: 0 }, + { name: 'PinnedSafe2', address: '0x2', isPinned: true, chainId: '2', isReadOnly: false, lastVisited: 0 }, + ] + + render() + + // SafesList should be rendered + expect(screen.getByTestId('safes-list')).toBeInTheDocument() + + // Check that it's called with the correct props + const callProps = (SafesList as jest.Mock).mock.calls[0][0] + expect(callProps.safes).toHaveLength(2) + expect(callProps.safes[0]).toEqual(pinnedSafes[0]) + expect(callProps.safes[1]).toEqual(pinnedSafes[1]) + expect(callProps.onLinkClick).toBeUndefined() + }) + + it('passes onLinkClick to SafesList if provided', () => { + const pinnedSafes: AllSafeItems = [ + { name: 'PinnedSafe1', address: '0x1', isPinned: true, chainId: '1', isReadOnly: false, lastVisited: 0 }, + ] + const onLinkClickMock = jest.fn() + + render() + + const callProps = (SafesList as jest.Mock).mock.calls[0][0] + expect(callProps.onLinkClick).toBe(onLinkClickMock) + }) + + it('shows empty pinned message when there are no pinned safes', () => { + const nonPinnedSafes: AllSafeItems = [ + { name: 'NotPinned', address: '0x3', isPinned: false, chainId: '3', isReadOnly: false, lastVisited: 0 }, + ] + + render() + + // SafesList should not be rendered + expect(screen.queryByTestId('safes-list')).not.toBeInTheDocument() + + // Empty pinned message should be visible + expect(screen.getByTestId('empty-pinned-list')).toBeInTheDocument() + expect(screen.getByText(/Personalize your account list by clicking the/i)).toBeInTheDocument() + }) + + it('shows empty pinned message if allSafes is empty', () => { + render() + expect(screen.queryByTestId('safes-list')).not.toBeInTheDocument() + expect(screen.getByTestId('empty-pinned-list')).toBeInTheDocument() + }) +}) diff --git a/src/features/myAccounts/components/PinnedSafes/index.tsx b/src/features/myAccounts/components/PinnedSafes/index.tsx new file mode 100644 index 0000000000..0386666bd5 --- /dev/null +++ b/src/features/myAccounts/components/PinnedSafes/index.tsx @@ -0,0 +1,39 @@ +import SafesList from '@/features/myAccounts/components/SafesList' +import type { AllSafeItems } from '@/features/myAccounts/hooks/useAllSafesGrouped' +import css from '@/features/myAccounts/styles.module.css' +import BookmarkIcon from '@/public/images/apps/bookmark.svg' +import { Box, SvgIcon, Typography } from '@mui/material' +import { useMemo } from 'react' + +const PinnedSafes = ({ allSafes, onLinkClick }: { allSafes: AllSafeItems; onLinkClick?: () => void }) => { + const pinnedSafes = useMemo(() => [...(allSafes?.filter(({ isPinned }) => isPinned) ?? [])], [allSafes]) + + return ( + +
+ + + Pinned + +
+ {pinnedSafes.length > 0 ? ( + + ) : ( + + + Personalize your account list by clicking the + + icon on the accounts most important to you. + + + )} +
+ ) +} + +export default PinnedSafes diff --git a/src/features/myAccounts/components/QueueActions/index.tsx b/src/features/myAccounts/components/QueueActions/index.tsx index 5da57be62e..f651de62c9 100644 --- a/src/features/myAccounts/components/QueueActions/index.tsx +++ b/src/features/myAccounts/components/QueueActions/index.tsx @@ -1,12 +1,13 @@ -import { useMemo, type ReactNode } from 'react' -import type { UrlObject } from 'url' -import NextLink from 'next/link' -import { Box, Chip, Typography, SvgIcon } from '@mui/material' +import classnames from 'classnames' +import { useRouter } from 'next/router' +import { type ReactNode, useCallback, type MouseEvent } from 'react' +import { Chip, Typography, SvgIcon } from '@mui/material' import CheckIcon from '@mui/icons-material/Check' import TransactionsIcon from '@/public/images/transactions/transactions.svg' import Track from '@/components/common/Track' import { OVERVIEW_EVENTS } from '@/services/analytics/events/overview' import { AppRoutes } from '@/config/routes' +import css from './styles.module.css' const ChipLink = ({ children, color }: { children: ReactNode; color?: string }) => ( { - const queueLink = useMemo( - () => ({ - pathname: AppRoutes.transactions.queue, - query: { safe: `${chainShortName}:${safeAddress}` }, - }), - [chainShortName, safeAddress], + const router = useRouter() + + const onQueueClick = useCallback( + (e: MouseEvent) => { + e.preventDefault() + router.push({ + pathname: AppRoutes.transactions.queue, + query: { safe: `${chainShortName}:${safeAddress}` }, + }) + }, + [chainShortName, router, safeAddress], ) if (!queued && !awaitingConfirmation) { @@ -54,31 +60,21 @@ const QueueActions = ({ return ( - - - {queued > 0 && ( - - - {queued} pending - - )} + ) } diff --git a/src/features/myAccounts/components/QueueActions/styles.module.css b/src/features/myAccounts/components/QueueActions/styles.module.css new file mode 100644 index 0000000000..7e51ee6f8e --- /dev/null +++ b/src/features/myAccounts/components/QueueActions/styles.module.css @@ -0,0 +1,15 @@ +.queueButton { + display: flex; + gap: var(--space-1); + align-items: center; + padding: 0; + border: 0; + cursor: pointer; + position: relative; + z-index: 1; + background: transparent; +} + +.isMobile { + padding: 0 var(--space-2) var(--space-2); +} diff --git a/src/features/myAccounts/hooks/useAllSafesGrouped.ts b/src/features/myAccounts/hooks/useAllSafesGrouped.ts index 6751d36f7a..021593a3dd 100644 --- a/src/features/myAccounts/hooks/useAllSafesGrouped.ts +++ b/src/features/myAccounts/hooks/useAllSafesGrouped.ts @@ -11,11 +11,13 @@ export type MultiChainSafeItem = { name: string | undefined } -export type AllSafesGrouped = { +export type AllSafeItemsGrouped = { allSingleSafes: SafeItems | undefined allMultiChainSafes: MultiChainSafeItem[] | undefined } +export type AllSafeItems = Array + export const _buildMultiChainSafeItem = (address: string, safes: SafeItems): MultiChainSafeItem => { const isPinned = safes.some((safe) => safe.isPinned) const lastVisited = safes.reduce((acc, safe) => Math.max(acc, safe.lastVisited || 0), 0) @@ -43,7 +45,7 @@ export const _getSingleChainAccounts = (safes: SafeItems, allMultiChainSafes: Mu export const useAllSafesGrouped = () => { const allSafes = useAllSafes() - return useMemo(() => { + return useMemo(() => { if (!allSafes) { return { allMultiChainSafes: undefined, allSingleSafes: undefined } } diff --git a/src/features/myAccounts/hooks/useSafesSearch.ts b/src/features/myAccounts/hooks/useSafesSearch.ts index c8fe879452..9ce2228def 100644 --- a/src/features/myAccounts/hooks/useSafesSearch.ts +++ b/src/features/myAccounts/hooks/useSafesSearch.ts @@ -1,13 +1,12 @@ import { useEffect, useMemo } from 'react' import Fuse from 'fuse.js' -import type { MultiChainSafeItem } from './useAllSafesGrouped' -import type { SafeItem } from './useAllSafes' +import type { AllSafeItems } from './useAllSafesGrouped' import { selectChains } from '@/store/chainsSlice' import { useAppSelector } from '@/store' import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' import { OVERVIEW_EVENTS, trackEvent } from '@/services/analytics' -const useSafesSearch = (safes: (SafeItem | MultiChainSafeItem)[], query: string): (SafeItem | MultiChainSafeItem)[] => { +const useSafesSearch = (safes: AllSafeItems, query: string): AllSafeItems => { const chains = useAppSelector(selectChains) useEffect(() => { diff --git a/src/features/myAccounts/hooks/useTrackedSafesCount.ts b/src/features/myAccounts/hooks/useTrackedSafesCount.ts index 9c90115828..d478da74d0 100644 --- a/src/features/myAccounts/hooks/useTrackedSafesCount.ts +++ b/src/features/myAccounts/hooks/useTrackedSafesCount.ts @@ -4,19 +4,14 @@ import { useRouter } from 'next/router' import { useEffect, useMemo } from 'react' import type { ConnectedWallet } from '@/hooks/wallets/useOnboard' import { type SafeItem } from './useAllSafes' -import type { AllSafesGrouped } from './useAllSafesGrouped' +import type { AllSafeItemsGrouped } from './useAllSafesGrouped' import { type MultiChainSafeItem } from './useAllSafesGrouped' import { isMultiChainSafeItem } from '@/features/multichain/utils/utils' let isOwnedSafesTracked = false let isPinnedSafesTracked = false -let isWatchlistTracked = false -const useTrackSafesCount = ( - safes: AllSafesGrouped, - pinnedSafes: (MultiChainSafeItem | SafeItem)[], - wallet: ConnectedWallet | null, -) => { +const useTrackSafesCount = (safes: AllSafeItemsGrouped, wallet: ConnectedWallet | null) => { const router = useRouter() const isLoginPage = router.pathname === AppRoutes.welcome.accounts @@ -25,25 +20,18 @@ const useTrackSafesCount = ( [safes], ) - // If all safes of a multichain account are on the watchlist we put the entire account on the watchlist - const watchlistMultiChainSafes = useMemo( - () => - safes.allMultiChainSafes?.filter((account) => - account.safes.some(({ isReadOnly, isPinned }) => isReadOnly && !isPinned), - ), - [safes], - ) - const ownedSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( () => [...(ownedMultiChainSafes ?? []), ...(safes.allSingleSafes?.filter(({ isReadOnly }) => !isReadOnly) ?? [])], [safes, ownedMultiChainSafes], ) - const watchlistSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( + + // TODO: This is computed here and inside PinnedSafes now. Find a way to optimize it + const pinnedSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( () => [ - ...(watchlistMultiChainSafes ?? []), - ...(safes.allSingleSafes?.filter(({ isReadOnly, isPinned }) => isReadOnly && !isPinned) ?? []), + ...(safes.allSingleSafes?.filter(({ isPinned }) => isPinned) ?? []), + ...(safes.allMultiChainSafes?.filter(({ isPinned }) => isPinned) ?? []), ], - [safes, watchlistMultiChainSafes], + [safes], ) // Reset tracking for new wallet @@ -72,17 +60,6 @@ const useTrackSafesCount = ( isPinnedSafesTracked = true } }, [isLoginPage, pinnedSafes]) - - useEffect(() => { - const totalSafesWatched = watchlistSafes?.reduce( - (prev, current) => prev + (isMultiChainSafeItem(current) ? current.safes.length : 1), - 0, - ) - if (watchlistSafes && isLoginPage && watchlistSafes.length > 0 && !isWatchlistTracked) { - trackEvent({ ...OVERVIEW_EVENTS.TOTAL_SAFES_WATCHLIST, label: totalSafesWatched }) - isWatchlistTracked = true - } - }, [isLoginPage, watchlistSafes]) } export default useTrackSafesCount diff --git a/src/features/myAccounts/index.tsx b/src/features/myAccounts/index.tsx index c14ecaca50..8b440075f1 100644 --- a/src/features/myAccounts/index.tsx +++ b/src/features/myAccounts/index.tsx @@ -1,271 +1,42 @@ -import { useCallback, useMemo, useState } from 'react' -import { - Accordion, - AccordionDetails, - AccordionSummary, - Box, - Button, - Divider, - InputAdornment, - Link, - Paper, - SvgIcon, - TextField, - Typography, -} from '@mui/material' -import debounce from 'lodash/debounce' +import AccountListFilters from 'src/features/myAccounts/components/AccountListFilters' +import AccountsHeader from '@/features/myAccounts/components/AccountsHeader' +import AccountsList from '@/features/myAccounts/components/AccountsList' +import { useState } from 'react' +import { Box, Divider, Paper } from '@mui/material' import madProps from '@/utils/mad-props' -import CreateButton from '@/features/myAccounts/components/CreateButton' -import AddIcon from '@/public/images/common/add.svg' -import Track from '@/components/common/Track' -import { OVERVIEW_EVENTS, OVERVIEW_LABELS } from '@/services/analytics' import css from '@/features/myAccounts/styles.module.css' -import SafesList from '@/features/myAccounts/components/SafesList' -import { AppRoutes } from '@/config/routes' import useWallet from '@/hooks/wallets/useWallet' -import { useRouter } from 'next/router' -import { - type AllSafesGrouped, - useAllSafesGrouped, - type MultiChainSafeItem, -} from '@/features/myAccounts/hooks/useAllSafesGrouped' -import { type SafeItem } from '@/features/myAccounts/hooks/useAllSafes' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import BookmarkIcon from '@/public/images/apps/bookmark.svg' +import { type AllSafeItemsGrouped, useAllSafesGrouped } from '@/features/myAccounts/hooks/useAllSafesGrouped' import classNames from 'classnames' -import { getComparator } from '@/features/myAccounts/utils/utils' -import SearchIcon from '@/public/images/common/search.svg' -import type { OrderByOption } from '@/store/orderByPreferenceSlice' -import { selectOrderByPreference, setOrderByPreference } from '@/store/orderByPreferenceSlice' -import { useAppDispatch, useAppSelector } from '@/store' -import { useSafesSearch } from '@/features/myAccounts/hooks/useSafesSearch' import useTrackSafesCount from '@/features/myAccounts/hooks/useTrackedSafesCount' import { DataWidget } from '@/features/myAccounts/components/DataWidget' -import OrderByButton from '@/features/myAccounts/components/OrderByButton' -import ConnectWalletButton from '@/components/common/ConnectWallet/ConnectWalletButton' -import { maybePlural } from '@/utils/formatters' -type AccountsListProps = { - safes: AllSafesGrouped +type MyAccountsProps = { + safes: AllSafeItemsGrouped isSidebar?: boolean onLinkClick?: () => void } -const AccountsList = ({ safes, onLinkClick, isSidebar = false }: AccountsListProps) => { +const MyAccounts = ({ safes, onLinkClick, isSidebar = false }: MyAccountsProps) => { const wallet = useWallet() - const router = useRouter() - const { orderBy } = useAppSelector(selectOrderByPreference) - const dispatch = useAppDispatch() - const sortComparator = getComparator(orderBy) const [searchQuery, setSearchQuery] = useState('') - - const allSafes = useMemo( - () => [...(safes.allMultiChainSafes ?? []), ...(safes.allSingleSafes ?? [])].sort(sortComparator), - [safes.allMultiChainSafes, safes.allSingleSafes, sortComparator], - ) - const filteredSafes = useSafesSearch(allSafes ?? [], searchQuery).sort(sortComparator) - - const pinnedSafes = useMemo<(MultiChainSafeItem | SafeItem)[]>( - () => [...(allSafes?.filter(({ isPinned }) => isPinned) ?? [])], - [allSafes], - ) - - const handleOrderByChange = (orderBy: OrderByOption) => { - dispatch(setOrderByPreference({ orderBy })) - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - const handleSearch = useCallback(debounce(setSearchQuery, 300), []) - - useTrackSafesCount(safes, pinnedSafes, wallet) - - const isLoginPage = router.pathname === AppRoutes.welcome.accounts - const trackingLabel = isLoginPage ? OVERVIEW_LABELS.login_page : OVERVIEW_LABELS.sidebar + useTrackSafesCount(safes, wallet) return ( - - - My accounts - - - - - - - - - {wallet ? ( - - - - ) : ( - - - - )} - - + - - - { - handleSearch(e.target.value) - }} - className={css.search} - InputProps={{ - startAdornment: ( - - - - ), - disableUnderline: true, - }} - fullWidth - size="small" - /> - - - + {isSidebar && } - {searchQuery ? ( - <> - {/* Search results */} - - Found {filteredSafes.length} result{maybePlural(filteredSafes)} - - - - - - ) : ( - <> - {/* Pinned Accounts */} - -
- - - Pinned - -
- {pinnedSafes.length > 0 ? ( - - ) : ( - - - Personalize your account list by clicking the - - icon on the accounts most important to you. - - - )} -
- - {/* All Accounts */} - - } - sx={{ - padding: 0, - '& .MuiAccordionSummary-content': { margin: '0 !important', mb: 1, flexGrow: 0 }, - }} - > -
- - Accounts - {allSafes && allSafes.length > 0 && ( - - {' '} - ({allSafes.length}) - - )} - -
-
- - {allSafes.length > 0 ? ( - - - - ) : ( - - {!wallet ? ( - <> - Connect a wallet to view your Safe Accounts or to create a new one - - - - - ) : ( - "You don't have any safes yet" - )} - - )} - -
- - )} +
+ {isSidebar && }
@@ -273,8 +44,6 @@ const AccountsList = ({ safes, onLinkClick, isSidebar = false }: AccountsListPro ) } -const MyAccounts = madProps(AccountsList, { +export default madProps(MyAccounts, { safes: useAllSafesGrouped, }) - -export default MyAccounts diff --git a/src/services/analytics/events/overview.ts b/src/services/analytics/events/overview.ts index 00a1d32d65..73e05c9bf2 100644 --- a/src/services/analytics/events/overview.ts +++ b/src/services/analytics/events/overview.ts @@ -57,11 +57,6 @@ export const OVERVIEW_EVENTS = { category: OVERVIEW_CATEGORY, event: EventType.META, }, - TOTAL_SAFES_WATCHLIST: { - action: 'Total Safes watchlist', - category: OVERVIEW_CATEGORY, - event: EventType.META, - }, SEARCH: { action: 'Search safes', category: OVERVIEW_CATEGORY, diff --git a/src/utils/__tests__/formatters.test.ts b/src/utils/__tests__/formatters.test.ts index 537fc985c8..c77424456e 100644 --- a/src/utils/__tests__/formatters.test.ts +++ b/src/utils/__tests__/formatters.test.ts @@ -107,7 +107,7 @@ describe('formatters', () => { it('should add an "s" for more than 1', () => { expect(maybePlural(2)).toEqual('s') expect(maybePlural(10)).toEqual('s') - expect(maybePlural(0)).toEqual('') + expect(maybePlural(0)).toEqual('s') expect(maybePlural(1)).toEqual('') }) diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index 065fa0c6af..0c2a9db86a 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -106,5 +106,5 @@ export const formatDurationFromMilliseconds = ( export const maybePlural = (quantity: number | unknown[]) => { quantity = Array.isArray(quantity) ? quantity.length : quantity - return quantity > 1 ? 's' : '' + return quantity === 1 ? '' : 's' }