diff --git a/cypress/e2e/smoke/dashboard.cy.js b/cypress/e2e/smoke/dashboard.cy.js index 02d8785a83..738183bbc1 100644 --- a/cypress/e2e/smoke/dashboard.cy.js +++ b/cypress/e2e/smoke/dashboard.cy.js @@ -58,11 +58,11 @@ describe('Dashboard', () => { cy.get(`main section#featured-safe-apps a[href*="?appUrl=http"]`).should('have.length', 2) }) - // it('should show the Safe Apps widget', () => { - // cy.contains('main section#safe-apps h2', 'Safe Apps') - // cy.contains('main section#safe-apps a[href="/app/apps"] button', 'Explore Safe Apps') + it('should show the Safe Apps Section', () => { + cy.contains('main section h2', 'Safe Apps') + cy.contains('main section ', 'Explore Safe Apps') - // // Regular safe apps - // cy.get(`main section#safe-apps a[href^="/app/${SAFE}/apps?appUrl=http"]`).should('have.length', 5) - // }) + // Regular safe apps + cy.get(`main section a[href^="/apps?safe=${SAFE}&appUrl=http"]`).should('have.length', 5) + }) }) diff --git a/jest.setup.js b/jest.setup.js index d0fa162674..bec7e2b84e 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -50,3 +50,9 @@ jest.mock('@web3-onboard/core', () => () => ({ get: () => mockOnboardState, }, })) + +// to avoid failing tests in some environments +const NumberFormat = Intl.NumberFormat +const englishTestLocale = 'en' + +jest.spyOn(Intl, 'NumberFormat').mockImplementation((locale, ...rest) => new NumberFormat([englishTestLocale], ...rest)) diff --git a/public/images/explore.svg b/public/images/explore.svg new file mode 100644 index 0000000000..63b976a833 --- /dev/null +++ b/public/images/explore.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx index 2b2f10b043..e928442b5e 100644 --- a/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx +++ b/src/components/common/SafeTokenWidget/__tests__/SafeTokenWidget.test.tsx @@ -112,6 +112,13 @@ describe('SafeTokenWidget', () => { loading: false, error: undefined, })) + // to avoid failing tests in some environments + const NumberFormat = Intl.NumberFormat + const englishTestLocale = 'en' + + jest + .spyOn(Intl, 'NumberFormat') + .mockImplementation((locale, ...rest) => new NumberFormat([englishTestLocale], ...rest)) const result = render() await waitFor(() => { diff --git a/src/components/dashboard/FeaturedApps/FeaturedApps.tsx b/src/components/dashboard/FeaturedApps/FeaturedApps.tsx index cea24c4e20..8db128a7db 100644 --- a/src/components/dashboard/FeaturedApps/FeaturedApps.tsx +++ b/src/components/dashboard/FeaturedApps/FeaturedApps.tsx @@ -39,7 +39,7 @@ export const FeaturedApps = (): ReactElement | null => { return ( - + Connect & transact diff --git a/src/components/dashboard/Overview/Overview.tsx b/src/components/dashboard/Overview/Overview.tsx index bf08cce932..e1e9e08dc3 100644 --- a/src/components/dashboard/Overview/Overview.tsx +++ b/src/components/dashboard/Overview/Overview.tsx @@ -86,7 +86,7 @@ const Overview = (): ReactElement => { return ( - + Overview diff --git a/src/components/dashboard/PendingTxs/PendingTxsList.tsx b/src/components/dashboard/PendingTxs/PendingTxsList.tsx index b6d57991e2..f3394824b1 100644 --- a/src/components/dashboard/PendingTxs/PendingTxsList.tsx +++ b/src/components/dashboard/PendingTxs/PendingTxsList.tsx @@ -84,7 +84,7 @@ const PendingTxsList = ({ size = 4 }: { size?: number }): ReactElement | null => return ( - + Transaction queue {totalQueuedTxs ? ` (${totalQueuedTxs})` : ''} {totalQueuedTxs > 0 && } diff --git a/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx b/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx new file mode 100644 index 0000000000..ce2840b99f --- /dev/null +++ b/src/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection.tsx @@ -0,0 +1,54 @@ +import { useRouter } from 'next/router' +import Typography from '@mui/material/Typography' +import Grid from '@mui/material/Grid' +import Box from '@mui/material/Box' +import Button from '@mui/material/Button' + +import { WidgetContainer } from '../styled' +import { useSafeApps } from '@/hooks/safe-apps/useSafeApps' +import { AppCard, AppCardContainer } from '@/components/safe-apps/AppCard' +import { AppRoutes } from '@/config/routes' +import ExploreSafeAppsIcon from '@/public/images/explore.svg' + +const SafeAppsDashboardSection = () => { + const { rankedSafeApps } = useSafeApps() + + return ( + + + Safe Apps + + + + {rankedSafeApps.map((rankedSafeApp) => ( + + + + ))} + + + + + + + ) +} + +export default SafeAppsDashboardSection + +const ExploreSafeAppsCard = () => { + const router = useRouter() + const safeAppsLink = `${AppRoutes.apps}?safe=${router.query.safe}` + + return ( + + + + + + + + ) +} diff --git a/src/components/dashboard/SafeAppsDashboardSection/__tests__/SafeAppsDashboardSection.test.tsx b/src/components/dashboard/SafeAppsDashboardSection/__tests__/SafeAppsDashboardSection.test.tsx new file mode 100644 index 0000000000..9616c36651 --- /dev/null +++ b/src/components/dashboard/SafeAppsDashboardSection/__tests__/SafeAppsDashboardSection.test.tsx @@ -0,0 +1,117 @@ +import * as safeAppsGatewaySDK from '@gnosis.pm/safe-react-gateway-sdk' +import { render, screen, waitFor } from '@/tests/test-utils' +import SafeAppsDashboardSection from '@/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection' + +jest.mock('@gnosis.pm/safe-react-gateway-sdk', () => ({ + ...jest.requireActual('@gnosis.pm/safe-react-gateway-sdk'), + getSafeApps: (chainId: string) => + Promise.resolve([ + { + id: 13, + url: 'https://cloudflare-ipfs.com/ipfs/QmX31xCdhFDmJzoVG33Y6kJtJ5Ujw8r5EJJBrsp8Fbjm7k', + name: 'Compound', + iconUrl: 'https://cloudflare-ipfs.com/ipfs/QmX31xCdhFDmJzoVG33Y6kJtJ5Ujw8r5EJJBrsp8Fbjm7k/Compound.png', + description: 'Money markets on the Ethereum blockchain', + chainIds: ['1', '4'], + provider: undefined, + accessControl: { + type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.NoRestrictions, + }, + tags: [], + }, + { + id: 3, + url: 'https://app.ens.domains', + name: 'ENS App', + iconUrl: 'https://app.ens.domains/android-chrome-144x144.png', + description: 'Decentralised naming for wallets, websites, & more.', + chainIds: ['1', '4'], + provider: undefined, + accessControl: { + type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.DomainAllowlist, + value: ['https://gnosis-safe.io'], + }, + tags: [], + }, + { + id: 14, + url: 'https://cloudflare-ipfs.com/ipfs/QmXLxxczMH4MBEYDeeN9zoiHDzVkeBmB5rBjA3UniPEFcA', + name: 'Synthetix', + iconUrl: 'https://cloudflare-ipfs.com/ipfs/QmXLxxczMH4MBEYDeeN9zoiHDzVkeBmB5rBjA3UniPEFcA/Synthetix.png', + description: 'Trade synthetic assets on Ethereum', + chainIds: ['1', '4'], + provider: undefined, + accessControl: { + type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.NoRestrictions, + }, + tags: [], + }, + { + id: 24, + url: 'https://cloudflare-ipfs.com/ipfs/QmdVaZxDov4bVARScTLErQSRQoxgqtBad8anWuw3YPQHCs', + name: 'Transaction Builder', + iconUrl: 'https://cloudflare-ipfs.com/ipfs/QmdVaZxDov4bVARScTLErQSRQoxgqtBad8anWuw3YPQHCs/tx-builder.png', + description: 'A Safe app to compose custom transactions', + chainIds: ['1', '4', '56', '100', '137', '246', '73799'], + provider: undefined, + accessControl: { + type: safeAppsGatewaySDK.SafeAppAccessPolicyTypes.DomainAllowlist, + value: ['https://gnosis-safe.io'], + }, + tags: [], + }, + ]), +})) + +describe('Safe Apps Dashboard Section', () => { + beforeEach(() => { + window.localStorage.clear() + const mostUsedApps = JSON.stringify({ + 24: { + openCount: 2, + timestamp: 1663779409409, + txCount: 1, + }, + 3: { + openCount: 1, + timestamp: 1663779409409, + txCount: 0, + }, + }) + window.localStorage.setItem('SAFE_v2__APPS_DASHBOARD', mostUsedApps) + }) + + afterEach(() => { + window.localStorage.clear() + }) + + it('should display the Safe Apps Section', async () => { + render() + + await waitFor(() => expect(screen.getByText('Safe Apps')).toBeInTheDocument()) + }) + + it('should display Safe Apps Cards (Name & Description)', async () => { + render() + + await waitFor(() => expect(screen.getByText('Compound')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('Money markets on the Ethereum blockchain')).toBeInTheDocument()) + + await waitFor(() => expect(screen.getByText('ENS App')).toBeInTheDocument()) + await waitFor(() => + expect(screen.getByText('Decentralised naming for wallets, websites, & more.')).toBeInTheDocument(), + ) + + await waitFor(() => expect(screen.getByText('Synthetix')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('Trade synthetic assets on Ethereum')).toBeInTheDocument()) + + await waitFor(() => expect(screen.getByText('Transaction Builder')).toBeInTheDocument()) + await waitFor(() => expect(screen.getByText('A Safe app to compose custom transactions')).toBeInTheDocument()) + }) + + it('should show the Explore Safe Apps Link', async () => { + render() + + await waitFor(() => expect(screen.getByText('Explore Safe Apps')).toBeInTheDocument()) + }) +}) diff --git a/src/components/dashboard/index.tsx b/src/components/dashboard/index.tsx index 86cb92043d..6f4ff288b6 100644 --- a/src/components/dashboard/index.tsx +++ b/src/components/dashboard/index.tsx @@ -3,6 +3,7 @@ import { Grid } from '@mui/material' import PendingTxsList from '@/components/dashboard/PendingTxs/PendingTxsList' import Overview from '@/components/dashboard/Overview/Overview' import { FeaturedApps } from '@/components/dashboard/FeaturedApps/FeaturedApps' +import SafeAppsDashboardSection from '@/components/dashboard/SafeAppsDashboardSection/SafeAppsDashboardSection' const Dashboard = (): ReactElement => { return ( @@ -15,7 +16,13 @@ const Dashboard = (): ReactElement => { - + + + + + + + ) } diff --git a/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx b/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx index 733859ba59..f900227cf2 100644 --- a/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx +++ b/src/components/safe-apps/SafeAppsTxModal/ReviewSafeAppsTx.tsx @@ -17,12 +17,13 @@ import { getInteractionTitle } from '../utils' import { SafeAppsTxParams } from '.' import { isEmptyHexData } from '@/utils/hex' import { dispatchSafeAppsTx } from '@/services/tx/txSender' +import { trackSafeAppTxCount } from '@/services/safe-apps/track-app-usage-count' type ReviewSafeAppsTxProps = { safeAppsTx: SafeAppsTxParams } -const ReviewSafeAppsTx = ({ safeAppsTx: { txs, requestId, params } }: ReviewSafeAppsTxProps): ReactElement => { +const ReviewSafeAppsTx = ({ safeAppsTx: { txs, requestId, params, appId } }: ReviewSafeAppsTxProps): ReactElement => { const chainId = useChainId() const chain = useCurrentChain() const { safe } = useSafeInfo() @@ -48,6 +49,7 @@ const ReviewSafeAppsTx = ({ safeAppsTx: { txs, requestId, params } }: ReviewSafe }, [safeTx, chainId]) const handleSubmit = (txId: string) => { + trackSafeAppTxCount(Number(appId)) dispatchSafeAppsTx(txId, requestId) } diff --git a/src/hooks/safe-apps/useRankedSafeApps.ts b/src/hooks/safe-apps/useRankedSafeApps.ts new file mode 100644 index 0000000000..aa88ef0811 --- /dev/null +++ b/src/hooks/safe-apps/useRankedSafeApps.ts @@ -0,0 +1,30 @@ +import { useMemo } from 'react' +import { sampleSize } from 'lodash' +import { SafeAppData } from '@gnosis.pm/safe-react-gateway-sdk' +import { getAppsUsageData, rankSafeApps } from '@/services/safe-apps/track-app-usage-count' + +// number of ranked Safe Apps that we include in the array +const NUMBER_OF_SAFE_APPS = 5 + +const useRankedSafeApps = (safeApps: SafeAppData[], pinnedSafeApps: SafeAppData[]): SafeAppData[] => { + return useMemo(() => { + if (!safeApps.length) return [] + + const usageSafeAppsData = getAppsUsageData() + const mostUsedSafeAppsIds = rankSafeApps(usageSafeAppsData, pinnedSafeApps).slice(0, NUMBER_OF_SAFE_APPS) + const mostUsedSafeApps = mostUsedSafeAppsIds + .map((id) => safeApps.find((safeApp) => String(safeApp.id) === id)) + .filter(Boolean) as SafeAppData[] + + // we add random Safe Apps if no enough Safe Apps are present + const numberOfRandomSafeApps = NUMBER_OF_SAFE_APPS - mostUsedSafeApps.length + const nonRankedApps = safeApps.filter((app) => !mostUsedSafeAppsIds.includes(String(app.id))) + const randomSafeApps = sampleSize(nonRankedApps, numberOfRandomSafeApps) + + const rankedSafeApps = [...mostUsedSafeApps, ...randomSafeApps] + + return rankedSafeApps + }, [safeApps, pinnedSafeApps]) +} + +export { useRankedSafeApps } diff --git a/src/hooks/safe-apps/useSafeApps.ts b/src/hooks/safe-apps/useSafeApps.ts index f6e279243b..81e898a447 100644 --- a/src/hooks/safe-apps/useSafeApps.ts +++ b/src/hooks/safe-apps/useSafeApps.ts @@ -3,6 +3,7 @@ import { SafeAppData } from '@gnosis.pm/safe-react-gateway-sdk' import { useRemoteSafeApps } from '@/hooks/safe-apps/useRemoteSafeApps' import { useCustomSafeApps } from '@/hooks/safe-apps/useCustomSafeApps' import { usePinnedSafeApps } from '@/hooks/safe-apps/usePinnedSafeApps' +import { useRankedSafeApps } from '@/hooks/safe-apps/useRankedSafeApps' type ReturnType = { allSafeApps: SafeAppData[] @@ -10,6 +11,7 @@ type ReturnType = { pinnedSafeAppIds: Set remoteSafeApps: SafeAppData[] customSafeApps: SafeAppData[] + rankedSafeApps: SafeAppData[] remoteSafeAppsLoading: boolean customSafeAppsLoading: boolean remoteSafeAppsError?: Error @@ -52,6 +54,8 @@ const useSafeApps = (): ReturnType => { [remoteSafeApps, pinnedSafeAppIds], ) + const rankedSafeApps = useRankedSafeApps(allSafeApps, pinnedSafeApps) + const addCustomApp = useCallback( (app: SafeAppData) => { updateCustomSafeApps([...customSafeApps, app]) @@ -83,6 +87,7 @@ const useSafeApps = (): ReturnType => { pinnedSafeApps, pinnedSafeAppIds, customSafeApps, + rankedSafeApps, remoteSafeAppsLoading, customSafeAppsLoading, remoteSafeAppsError,