From 0df51076a926ec767ce6276cf5d9de05f56a3271 Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Fri, 1 Nov 2024 11:32:33 +0200 Subject: [PATCH 01/20] FET-1689: Name Renew Deeplink Params --- src/components/ProfileSnippet.tsx | 24 +++++++++++++++---- .../input/ExtendNames/ExtendNames-flow.tsx | 9 +++++-- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/src/components/ProfileSnippet.tsx b/src/components/ProfileSnippet.tsx index 148dc7b1c..25f6bb582 100644 --- a/src/components/ProfileSnippet.tsx +++ b/src/components/ProfileSnippet.tsx @@ -1,3 +1,4 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' import { useSearchParams } from 'next/navigation' import { useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' @@ -173,6 +174,14 @@ export const getUserDefinedUrl = (url?: string) => { return `` } +const parseNumericString = (time: string | null) => { + if (!time) return + + if (typeof +time === 'number' && !Number.isNaN(+time)) { + return +time + } +} + export const ProfileSnippet = ({ name, getTextRecord, @@ -192,6 +201,7 @@ export const ProfileSnippet = ({ const router = useRouterWithHistory() const { t } = useTranslation('common') + const { openConnectModal } = useConnectModal() const { usePreparedDataInput } = useTransactionFlow() const showExtendNamesInput = usePreparedDataInput('ExtendNames') const abilities = useAbilities({ name }) @@ -208,24 +218,30 @@ export const ProfileSnippet = ({ const searchParams = useSearchParams() - const renew = (searchParams.get('renew') ?? null) !== null const available = details.registrationStatus === 'available' const { canSelfExtend, canEdit } = abilities.data ?? {} + const renewSeconds = parseNumericString(searchParams.get('renew')) + useEffect(() => { - if (renew && !isConnected) { + if (renewSeconds && available) { return router.push(`/${name}/register`) } - if (renew && !available) { + if (renewSeconds && !available && !isConnected) { + return openConnectModal?.() + } + + if (renewSeconds && !available && isConnected) { showExtendNamesInput(`extend-names-${name}`, { names: [name], isSelf: canSelfExtend, + seconds: renewSeconds, }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isConnected, available, renew, name, canSelfExtend]) + }, [isConnected, available, renewSeconds, name, canSelfExtend, openConnectModal]) const ActionButton = useMemo(() => { if (button === 'extend') diff --git a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx index 50343fdf2..ac308fec1 100644 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx +++ b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx @@ -160,6 +160,7 @@ const NamesList = ({ names }: NamesListProps) => { type Data = { names: string[] + seconds?: number isSelf?: boolean } @@ -169,7 +170,11 @@ export type Props = { const minSeconds = ONE_DAY -const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => { +const ExtendNames = ({ + data: { seconds: defaultSeconds = ONE_YEAR, names, isSelf }, + dispatch, + onDismiss, +}: Props) => { const { t } = useTranslation(['transactionFlow', 'common']) const { data: ethPrice } = useEthPrice() @@ -195,7 +200,7 @@ const ExtendNames = ({ data: { names, isSelf }, dispatch, onDismiss }: Props) => const decrementView = () => (viewIdx <= 0 ? onDismiss() : setViewIdx(viewIdx - 1)) const view = flow[viewIdx] - const [seconds, setSeconds] = useState(ONE_YEAR) + const [seconds, setSeconds] = useState(Math.max(defaultSeconds, ONE_YEAR)) const [durationType, setDurationType] = useState<'years' | 'date'>('years') const years = secondsToYears(seconds) From 30d96fa9ccf57a810bbdc1ea8d3922aeb3101806 Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Fri, 1 Nov 2024 11:56:27 +0200 Subject: [PATCH 02/20] test patch --- e2e/specs/stateless/extendNames.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/specs/stateless/extendNames.spec.ts b/e2e/specs/stateless/extendNames.spec.ts index fb2e7f9d8..725d87949 100644 --- a/e2e/specs/stateless/extendNames.spec.ts +++ b/e2e/specs/stateless/extendNames.spec.ts @@ -624,7 +624,7 @@ test('should be able to extend a single wrapped name using deep link', async ({ const homePage = makePageObject('HomePage') await homePage.goto() await login.connect() - await page.goto(`/${name}?renew`) + await page.goto(`/${name}?renew=123`) const timestamp = await profilePage.getExpiryTimestamp() @@ -670,7 +670,7 @@ test('should not be able to extend a name which is not registered', async ({ const homePage = makePageObject('HomePage') await homePage.goto() await login.connect() - await page.goto(`/${name}?renew`) + await page.goto(`/${name}?renew=123`) await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() }) @@ -681,6 +681,6 @@ test('renew deep link should redirect to registration when not logged in', async const name = 'this-name-does-not-exist.eth' const homePage = makePageObject('HomePage') await homePage.goto() - await page.goto(`/${name}?renew`) + await page.goto(`/${name}?renew=123`) await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() }) From f7eaf503ffb36b6cae0353d98ffc2ca59fcca042 Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Mon, 4 Nov 2024 09:45:13 +0200 Subject: [PATCH 03/20] extend modal patch --- src/components/ProfileSnippet.tsx | 42 +----------- .../pages/profile/[name]/Profile.tsx | 67 ++++++++++++++++++- 2 files changed, 67 insertions(+), 42 deletions(-) diff --git a/src/components/ProfileSnippet.tsx b/src/components/ProfileSnippet.tsx index 25f6bb582..503b7d28a 100644 --- a/src/components/ProfileSnippet.tsx +++ b/src/components/ProfileSnippet.tsx @@ -1,9 +1,6 @@ -import { useConnectModal } from '@rainbow-me/rainbowkit' -import { useSearchParams } from 'next/navigation' -import { useEffect, useMemo } from 'react' +import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' -import { useAccount } from 'wagmi' import { Button, mq, NametagSVG, Tag, Typography } from '@ensdomains/thorin' @@ -11,7 +8,6 @@ import FastForwardSVG from '@app/assets/FastForward.svg' import VerifiedPersonSVG from '@app/assets/VerifiedPerson.svg' import { useAbilities } from '@app/hooks/abilities/useAbilities' import { useBeautifiedName } from '@app/hooks/useBeautifiedName' -import { useNameDetails } from '@app/hooks/useNameDetails' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { useTransactionFlow } from '../transaction-flow/TransactionFlowProvider' @@ -174,14 +170,6 @@ export const getUserDefinedUrl = (url?: string) => { return `` } -const parseNumericString = (time: string | null) => { - if (!time) return - - if (typeof +time === 'number' && !Number.isNaN(+time)) { - return +time - } -} - export const ProfileSnippet = ({ name, getTextRecord, @@ -201,12 +189,9 @@ export const ProfileSnippet = ({ const router = useRouterWithHistory() const { t } = useTranslation('common') - const { openConnectModal } = useConnectModal() const { usePreparedDataInput } = useTransactionFlow() const showExtendNamesInput = usePreparedDataInput('ExtendNames') const abilities = useAbilities({ name }) - const details = useNameDetails({ name }) - const { isConnected } = useAccount() const beautifiedName = useBeautifiedName(name) @@ -216,33 +201,8 @@ export const ProfileSnippet = ({ const location = getTextRecord?.('location')?.value const recordName = getTextRecord?.('name')?.value - const searchParams = useSearchParams() - - const available = details.registrationStatus === 'available' - const { canSelfExtend, canEdit } = abilities.data ?? {} - const renewSeconds = parseNumericString(searchParams.get('renew')) - - useEffect(() => { - if (renewSeconds && available) { - return router.push(`/${name}/register`) - } - - if (renewSeconds && !available && !isConnected) { - return openConnectModal?.() - } - - if (renewSeconds && !available && isConnected) { - showExtendNamesInput(`extend-names-${name}`, { - names: [name], - isSelf: canSelfExtend, - seconds: renewSeconds, - }) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isConnected, available, renewSeconds, name, canSelfExtend, openConnectModal]) - const ActionButton = useMemo(() => { if (button === 'extend') return ( diff --git a/src/components/pages/profile/[name]/Profile.tsx b/src/components/pages/profile/[name]/Profile.tsx index 68847c87f..035c54b32 100644 --- a/src/components/pages/profile/[name]/Profile.tsx +++ b/src/components/pages/profile/[name]/Profile.tsx @@ -1,9 +1,11 @@ import Head from 'next/head' -import { useEffect, useMemo } from 'react' +import { useSearchParams } from 'next/navigation' +import { useEffect, useMemo, useState } from 'react' import { Trans, useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { match } from 'ts-pattern' import { useAccount } from 'wagmi' +import { useConnectModal } from '@rainbow-me/rainbowkit' import { Banner, CheckCircleSVG, Typography } from '@ensdomains/thorin' @@ -16,6 +18,7 @@ import { useProtectedRoute } from '@app/hooks/useProtectedRoute' import { useQueryParameterState } from '@app/hooks/useQueryParameterState' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { Content, ContentWarning } from '@app/layouts/Content' +import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' import { OG_IMAGE_URL } from '@app/utils/constants' import { shouldRedirect } from '@app/utils/shouldRedirect' import { formatFullExpiry, makeEtherscanLink } from '@app/utils/utils' @@ -104,6 +107,67 @@ export const NameAvailableBanner = ({ ) } +function useRenew(name: string) { + const parseNumericString = (time: string | null) => { + if (!time) return + + if (typeof +time === 'number' && !Number.isNaN(+time)) { + return +time + } + } + + const [opened, setOpened] = useState(false) + const router = useRouterWithHistory() + + const { registrationStatus, isLoading } = useNameDetails({ name }) + const abilities = useAbilities({ name }) + const searchParams = useSearchParams() + const { isConnected, isDisconnected } = useAccount() + const { usePreparedDataInput } = useTransactionFlow() + const { openConnectModal } = useConnectModal() + const showExtendNamesInput = usePreparedDataInput('ExtendNames') + + const { data: { canSelfExtend } = {} } = abilities + const isAvailableName = registrationStatus === 'available' + const renewSeconds = parseNumericString(searchParams.get('renew')) + + useEffect(() => { + if (opened || !renewSeconds || isLoading) return + + if (isAvailableName) { + setOpened(true) + router.push(`/${name}/register`) + return + } + + if (!isAvailableName && isDisconnected) { + setOpened(true) + openConnectModal?.() + return + } + + if (!isAvailableName && isConnected) { + setOpened(true) + showExtendNamesInput(`extend-names-${name}`, { + names: [name], + isSelf: canSelfExtend, + seconds: renewSeconds, + }) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + isAvailableName, + isLoading, + isConnected, + isDisconnected, + name, + canSelfExtend, + renewSeconds, + opened, + ]) +} + const ProfileContent = ({ isSelf, isLoading: parentIsLoading, name }: Props) => { const router = useRouterWithHistory() const { t } = useTranslation('profile') @@ -181,6 +245,7 @@ const ProfileContent = ({ isSelf, isLoading: parentIsLoading, name }: Props) => const abilities = useAbilities({ name: normalisedName }) + useRenew(normalisedName) // hook for redirecting to the correct profile url // profile.decryptedName fetches labels from NW/subgraph // normalisedName fetches labels from localStorage From b9719617a1a5cf11d4e17f6d41fe1c33ee4e2aa7 Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Mon, 4 Nov 2024 10:03:37 +0200 Subject: [PATCH 04/20] prettier format --- src/components/pages/profile/[name]/Profile.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/pages/profile/[name]/Profile.tsx b/src/components/pages/profile/[name]/Profile.tsx index 035c54b32..e42be984c 100644 --- a/src/components/pages/profile/[name]/Profile.tsx +++ b/src/components/pages/profile/[name]/Profile.tsx @@ -1,3 +1,4 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' import Head from 'next/head' import { useSearchParams } from 'next/navigation' import { useEffect, useMemo, useState } from 'react' @@ -5,7 +6,6 @@ import { Trans, useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { match } from 'ts-pattern' import { useAccount } from 'wagmi' -import { useConnectModal } from '@rainbow-me/rainbowkit' import { Banner, CheckCircleSVG, Typography } from '@ensdomains/thorin' From 42ddd5cf305d29060ae392ba113222349e6b1a1d Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Mon, 4 Nov 2024 12:07:20 +0200 Subject: [PATCH 05/20] increase timeout --- e2e/specs/stateless/extendNames.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/e2e/specs/stateless/extendNames.spec.ts b/e2e/specs/stateless/extendNames.spec.ts index 725d87949..459ca0336 100644 --- a/e2e/specs/stateless/extendNames.spec.ts +++ b/e2e/specs/stateless/extendNames.spec.ts @@ -92,7 +92,7 @@ test('should be able to extend multiple names on the address page', async ({ await page.waitForLoadState('networkidle') await expect(page.getByText('Your "Extend names" transaction was successful')).toBeVisible({ - timeout: 10000, + timeout: 15000, }) await subgraph.sync() From 8581536bc1aa4604336705ee6a2aa894474fd8e6 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Tue, 19 Nov 2024 13:09:34 +0800 Subject: [PATCH 06/20] add useRenew hook placeholder --- src/hooks/pages/profile/useRenew/useRenew.ts | 1 + 1 file changed, 1 insertion(+) create mode 100644 src/hooks/pages/profile/useRenew/useRenew.ts diff --git a/src/hooks/pages/profile/useRenew/useRenew.ts b/src/hooks/pages/profile/useRenew/useRenew.ts new file mode 100644 index 000000000..778120973 --- /dev/null +++ b/src/hooks/pages/profile/useRenew/useRenew.ts @@ -0,0 +1 @@ +export const useRenew = () => {} \ No newline at end of file From 6bdf235311db532f045e8cc65ba62c90c17a3a32 Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Tue, 19 Nov 2024 10:24:27 +0200 Subject: [PATCH 07/20] . --- src/hooks/pages/profile/useRenew/useRenew.ts | 1 - 1 file changed, 1 deletion(-) delete mode 100644 src/hooks/pages/profile/useRenew/useRenew.ts diff --git a/src/hooks/pages/profile/useRenew/useRenew.ts b/src/hooks/pages/profile/useRenew/useRenew.ts deleted file mode 100644 index 778120973..000000000 --- a/src/hooks/pages/profile/useRenew/useRenew.ts +++ /dev/null @@ -1 +0,0 @@ -export const useRenew = () => {} \ No newline at end of file From e6df0a9923f2b27439887ec5aa8f3f44f12b2e23 Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Tue, 19 Nov 2024 10:35:50 +0200 Subject: [PATCH 08/20] review updates --- src/hooks/pages/profile/useRenew.ts | 50 +++++++++++++++++++ .../input/ExtendNames/ExtendNames-flow.tsx | 2 +- src/utils/string.ts | 9 ++++ 3 files changed, 60 insertions(+), 1 deletion(-) create mode 100644 src/hooks/pages/profile/useRenew.ts create mode 100644 src/utils/string.ts diff --git a/ src/hooks/pages/profile/useRenew.ts b/ src/hooks/pages/profile/useRenew.ts new file mode 100644 index 000000000..8b6e17f7f --- /dev/null +++ b/ src/hooks/pages/profile/useRenew.ts @@ -0,0 +1,50 @@ +import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useSearchParams } from 'next/navigation' +import { useEffect, useState } from 'react' +import { useAccount } from 'wagmi' + +import { useAbilities } from '@app/hooks/abilities/useAbilities' +import { useNameDetails } from '@app/hooks/useNameDetails' +import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' +import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { parseNumericString } from '@app/utils/string' + +export function useRenew(name: string) { + const [opened, setOpened] = useState(false) + const router = useRouterWithHistory() + + const { registrationStatus, isLoading } = useNameDetails({ name }) + const abilities = useAbilities({ name }) + const searchParams = useSearchParams() + const { isConnected, isDisconnected } = useAccount() + const { usePreparedDataInput } = useTransactionFlow() + const { openConnectModal } = useConnectModal() + const showExtendNamesInput = usePreparedDataInput('ExtendNames') + + const { data: { canSelfExtend } = {} } = abilities + const isAvailableName = registrationStatus === 'available' + const renewSeconds = parseNumericString(searchParams.get('renew')) + + const isRenewActive = !opened && !!renewSeconds && !isLoading + + useEffect(() => { + if (!isRenewActive) return + + if (!isAvailableName && isDisconnected) { + setOpened(true) + openConnectModal?.() + return + } + + if (!isAvailableName && isConnected) { + setOpened(true) + showExtendNamesInput(`extend-names-${name}`, { + names: [name], + isSelf: canSelfExtend, + seconds: renewSeconds, + }) + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRenewActive, isAvailableName, isConnected, isDisconnected, name, canSelfExtend]) +} diff --git a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx index 7f374ef1e..22d2d61b2 100644 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx +++ b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx @@ -178,7 +178,7 @@ const ExtendNames = ({ }: Props) => { const { t } = useTranslation(['transactionFlow', 'common']) - const [seconds, setSeconds] = useState(Math.max(defaultSeconds, ONE_YEAR)) + const [seconds, setSeconds] = useState(defaultSeconds) const years = secondsToYears(seconds) const [durationType, setDurationType] = useState<'years' | 'date'>('years') diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 000000000..c2467d224 --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,9 @@ +export const parseNumericString = (time: string | null): number | null => { + if (!time) return null + + if (typeof +time === 'number' && !Number.isNaN(+time)) { + return +time + } + + return null +} From 36c7d894347984bff05c8ddfbf2e01b3d234e5d7 Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Tue, 19 Nov 2024 10:39:30 +0200 Subject: [PATCH 09/20] . --- src/hooks/pages/profile/useRenew.ts | 2 - .../pages/profile/[name]/Profile.tsx | 67 +------------------ 2 files changed, 2 insertions(+), 67 deletions(-) diff --git a/ src/hooks/pages/profile/useRenew.ts b/ src/hooks/pages/profile/useRenew.ts index 8b6e17f7f..2a8706d15 100644 --- a/ src/hooks/pages/profile/useRenew.ts +++ b/ src/hooks/pages/profile/useRenew.ts @@ -5,13 +5,11 @@ import { useAccount } from 'wagmi' import { useAbilities } from '@app/hooks/abilities/useAbilities' import { useNameDetails } from '@app/hooks/useNameDetails' -import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' import { parseNumericString } from '@app/utils/string' export function useRenew(name: string) { const [opened, setOpened] = useState(false) - const router = useRouterWithHistory() const { registrationStatus, isLoading } = useNameDetails({ name }) const abilities = useAbilities({ name }) diff --git a/src/components/pages/profile/[name]/Profile.tsx b/src/components/pages/profile/[name]/Profile.tsx index e42be984c..ac9940a62 100644 --- a/src/components/pages/profile/[name]/Profile.tsx +++ b/src/components/pages/profile/[name]/Profile.tsx @@ -1,7 +1,6 @@ -import { useConnectModal } from '@rainbow-me/rainbowkit' +import { useRenew } from '@root/ src/hooks/pages/profile/useRenew' import Head from 'next/head' -import { useSearchParams } from 'next/navigation' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' import styled, { css } from 'styled-components' import { match } from 'ts-pattern' @@ -18,7 +17,6 @@ import { useProtectedRoute } from '@app/hooks/useProtectedRoute' import { useQueryParameterState } from '@app/hooks/useQueryParameterState' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { Content, ContentWarning } from '@app/layouts/Content' -import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' import { OG_IMAGE_URL } from '@app/utils/constants' import { shouldRedirect } from '@app/utils/shouldRedirect' import { formatFullExpiry, makeEtherscanLink } from '@app/utils/utils' @@ -107,67 +105,6 @@ export const NameAvailableBanner = ({ ) } -function useRenew(name: string) { - const parseNumericString = (time: string | null) => { - if (!time) return - - if (typeof +time === 'number' && !Number.isNaN(+time)) { - return +time - } - } - - const [opened, setOpened] = useState(false) - const router = useRouterWithHistory() - - const { registrationStatus, isLoading } = useNameDetails({ name }) - const abilities = useAbilities({ name }) - const searchParams = useSearchParams() - const { isConnected, isDisconnected } = useAccount() - const { usePreparedDataInput } = useTransactionFlow() - const { openConnectModal } = useConnectModal() - const showExtendNamesInput = usePreparedDataInput('ExtendNames') - - const { data: { canSelfExtend } = {} } = abilities - const isAvailableName = registrationStatus === 'available' - const renewSeconds = parseNumericString(searchParams.get('renew')) - - useEffect(() => { - if (opened || !renewSeconds || isLoading) return - - if (isAvailableName) { - setOpened(true) - router.push(`/${name}/register`) - return - } - - if (!isAvailableName && isDisconnected) { - setOpened(true) - openConnectModal?.() - return - } - - if (!isAvailableName && isConnected) { - setOpened(true) - showExtendNamesInput(`extend-names-${name}`, { - names: [name], - isSelf: canSelfExtend, - seconds: renewSeconds, - }) - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - isAvailableName, - isLoading, - isConnected, - isDisconnected, - name, - canSelfExtend, - renewSeconds, - opened, - ]) -} - const ProfileContent = ({ isSelf, isLoading: parentIsLoading, name }: Props) => { const router = useRouterWithHistory() const { t } = useTranslation('profile') From 83df10650fbebfb90f6b7c48f67321967cd79ea4 Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Tue, 19 Nov 2024 11:39:42 +0200 Subject: [PATCH 10/20] remove opened flag --- src/hooks/pages/profile/useRenew.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/ src/hooks/pages/profile/useRenew.ts b/ src/hooks/pages/profile/useRenew.ts index 2a8706d15..4e19d1449 100644 --- a/ src/hooks/pages/profile/useRenew.ts +++ b/ src/hooks/pages/profile/useRenew.ts @@ -1,6 +1,6 @@ import { useConnectModal } from '@rainbow-me/rainbowkit' import { useSearchParams } from 'next/navigation' -import { useEffect, useState } from 'react' +import { useEffect, useRef } from 'react' import { useAccount } from 'wagmi' import { useAbilities } from '@app/hooks/abilities/useAbilities' @@ -9,33 +9,33 @@ import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvide import { parseNumericString } from '@app/utils/string' export function useRenew(name: string) { - const [opened, setOpened] = useState(false) - const { registrationStatus, isLoading } = useNameDetails({ name }) const abilities = useAbilities({ name }) const searchParams = useSearchParams() const { isConnected, isDisconnected } = useAccount() const { usePreparedDataInput } = useTransactionFlow() - const { openConnectModal } = useConnectModal() + const { openConnectModal, connectModalOpen } = useConnectModal() const showExtendNamesInput = usePreparedDataInput('ExtendNames') const { data: { canSelfExtend } = {} } = abilities const isAvailableName = registrationStatus === 'available' const renewSeconds = parseNumericString(searchParams.get('renew')) - const isRenewActive = !opened && !!renewSeconds && !isLoading + const prevIsConnected = useRef(isConnected) + + const isRenewActive = + (!isConnected || !connectModalOpen) && !!renewSeconds && !isLoading && !isAvailableName + // http://localhost:3000/anyname.eth?renew=63072000 useEffect(() => { if (!isRenewActive) return - if (!isAvailableName && isDisconnected) { - setOpened(true) + if (isDisconnected && prevIsConnected.current) { openConnectModal?.() return } - if (!isAvailableName && isConnected) { - setOpened(true) + if (isConnected) { showExtendNamesInput(`extend-names-${name}`, { names: [name], isSelf: canSelfExtend, @@ -43,6 +43,8 @@ export function useRenew(name: string) { }) } + prevIsConnected.current = !isConnected + // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isRenewActive, isAvailableName, isConnected, isDisconnected, name, canSelfExtend]) + }, [isRenewActive, isConnected, isDisconnected, name, canSelfExtend]) } From 9c56c21d0293dc237f4ae2ff92684ad19e1b9f7b Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Wed, 20 Nov 2024 13:40:02 +0800 Subject: [PATCH 11/20] add tests and clean up folder structure --- .../pages/profile/[name]/Profile.tsx | 2 +- .../hooks/pages/profile/useRenew}/useRenew.ts | 0 src/utils/string.test.ts | 28 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) rename { src/hooks/pages/profile => src/hooks/pages/profile/useRenew}/useRenew.ts (100%) create mode 100644 src/utils/string.test.ts diff --git a/src/components/pages/profile/[name]/Profile.tsx b/src/components/pages/profile/[name]/Profile.tsx index ac9940a62..a34786aa3 100644 --- a/src/components/pages/profile/[name]/Profile.tsx +++ b/src/components/pages/profile/[name]/Profile.tsx @@ -1,4 +1,3 @@ -import { useRenew } from '@root/ src/hooks/pages/profile/useRenew' import Head from 'next/head' import { useEffect, useMemo } from 'react' import { Trans, useTranslation } from 'react-i18next' @@ -12,6 +11,7 @@ import BaseLink from '@app/components/@atoms/BaseLink' import { Outlink } from '@app/components/Outlink' import { useAbilities } from '@app/hooks/abilities/useAbilities' import { useChainName } from '@app/hooks/chain/useChainName' +import { useRenew } from '@app/hooks/pages/profile/useRenew/useRenew' import { useNameDetails } from '@app/hooks/useNameDetails' import { useProtectedRoute } from '@app/hooks/useProtectedRoute' import { useQueryParameterState } from '@app/hooks/useQueryParameterState' diff --git a/ src/hooks/pages/profile/useRenew.ts b/src/hooks/pages/profile/useRenew/useRenew.ts similarity index 100% rename from src/hooks/pages/profile/useRenew.ts rename to src/hooks/pages/profile/useRenew/useRenew.ts diff --git a/src/utils/string.test.ts b/src/utils/string.test.ts new file mode 100644 index 000000000..7c079b315 --- /dev/null +++ b/src/utils/string.test.ts @@ -0,0 +1,28 @@ +import { it, describe, expect } from "vitest"; +import { parseNumericString } from "./string"; + +describe('parseNumericString', () => { + it('should return an integer', () => { + expect(parseNumericString('123')).toBe(123) + }) + + it('should return an integer for a decimal', () => { + expect(parseNumericString('123.123')).toBe(123) + }) + + it('should return null for a string', () => { + expect(parseNumericString('abc')).toBe(null) + }) + + it('should return null for an empty string', () => { + expect(parseNumericString('')).toBe(null) + }) + + it('should return null for a negative number', () => { + expect(parseNumericString('-123')).toBe(null) + }) + + it('should return null for a negative number', () => { + expect(parseNumericString('1a23')).toBe(null) + }) +}) \ No newline at end of file From f74504b388b092af12dadc0e0ac5f03272c94c3e Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Wed, 20 Nov 2024 19:41:02 +0800 Subject: [PATCH 12/20] refactor useRew.ts --- src/hooks/pages/profile/useRenew/useRenew.ts | 99 ++++++++++++++------ 1 file changed, 70 insertions(+), 29 deletions(-) diff --git a/src/hooks/pages/profile/useRenew/useRenew.ts b/src/hooks/pages/profile/useRenew/useRenew.ts index 4e19d1449..fe7b34e47 100644 --- a/src/hooks/pages/profile/useRenew/useRenew.ts +++ b/src/hooks/pages/profile/useRenew/useRenew.ts @@ -1,50 +1,91 @@ import { useConnectModal } from '@rainbow-me/rainbowkit' import { useSearchParams } from 'next/navigation' -import { useEffect, useRef } from 'react' +import { useEffect } from 'react' +import { match } from 'ts-pattern' import { useAccount } from 'wagmi' import { useAbilities } from '@app/hooks/abilities/useAbilities' -import { useNameDetails } from '@app/hooks/useNameDetails' +import { useBasicName } from '@app/hooks/useBasicName' +import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import { RegistrationStatus } from '@app/utils/registrationStatus' import { parseNumericString } from '@app/utils/string' +type RenewStatus = 'connect-user' | 'display-extend-names' | 'idle' + +export const calculateRenewState = ({ + registrationStatus, + isRegistrationStatusLoading, + renewSeconds, + openConnectModal, + connectModalOpen, + accountStatus, + isAbilitiesLoading, + name, +}: { + registrationStatus?: RegistrationStatus + isRegistrationStatusLoading: boolean + renewSeconds: number | null + connectModalOpen: boolean + openConnectModal: ReturnType['openConnectModal'] + accountStatus: ReturnType['status'] + isAbilitiesLoading: boolean + name?: string +}): RenewStatus => { + const isNameRegisteredOrGracePeriod = + registrationStatus === 'registered' || registrationStatus === 'gracePeriod' + const isRenewActive = + !isRegistrationStatusLoading && + !!name && + isNameRegisteredOrGracePeriod && + !!renewSeconds && + !connectModalOpen + + if (isRenewActive && accountStatus === 'disconnected' && !!openConnectModal) return 'connect-user' + if (isRenewActive && accountStatus === 'connected' && !isAbilitiesLoading) + return 'display-extend-names' + return 'idle' +} + export function useRenew(name: string) { - const { registrationStatus, isLoading } = useNameDetails({ name }) + const router = useRouterWithHistory() + const { registrationStatus, isLoading: isBasicNameLoading } = useBasicName({ name }) const abilities = useAbilities({ name }) const searchParams = useSearchParams() - const { isConnected, isDisconnected } = useAccount() - const { usePreparedDataInput } = useTransactionFlow() + const { status } = useAccount() const { openConnectModal, connectModalOpen } = useConnectModal() + + const { usePreparedDataInput } = useTransactionFlow() const showExtendNamesInput = usePreparedDataInput('ExtendNames') - const { data: { canSelfExtend } = {} } = abilities - const isAvailableName = registrationStatus === 'available' - const renewSeconds = parseNumericString(searchParams.get('renew')) + const { data: { canSelfExtend } = {}, isLoading: isAbilitiesLoading } = abilities - const prevIsConnected = useRef(isConnected) + const renewSeconds = parseNumericString(searchParams.get('renew')) - const isRenewActive = - (!isConnected || !connectModalOpen) && !!renewSeconds && !isLoading && !isAvailableName + const renewState = calculateRenewState({ + registrationStatus, + isRegistrationStatusLoading: isBasicNameLoading, + renewSeconds, + connectModalOpen, + accountStatus: status, + isAbilitiesLoading, + name, + openConnectModal, + }) - // http://localhost:3000/anyname.eth?renew=63072000 useEffect(() => { - if (!isRenewActive) return - - if (isDisconnected && prevIsConnected.current) { - openConnectModal?.() - return - } - - if (isConnected) { - showExtendNamesInput(`extend-names-${name}`, { - names: [name], - isSelf: canSelfExtend, - seconds: renewSeconds, + match(renewState) + .with('connect-user', () => openConnectModal?.()) + .with('display-extend-names', () => { + showExtendNamesInput(`extend-names-${name}`, { + names: [name], + isSelf: canSelfExtend, + seconds: renewSeconds!, + }) + router.replace(`/${name}`) }) - } - - prevIsConnected.current = !isConnected - + .with('idle', () => {}) + .exhaustive() // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isRenewActive, isConnected, isDisconnected, name, canSelfExtend]) + }, [renewState]) } From c0ae83941324f56489a6bbb0b13c39fc9b9fa238 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Wed, 20 Nov 2024 21:06:48 +0800 Subject: [PATCH 13/20] added additional condition for router ready and function to remove renew parameter from query --- src/hooks/pages/profile/useRenew/useRenew.ts | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/hooks/pages/profile/useRenew/useRenew.ts b/src/hooks/pages/profile/useRenew/useRenew.ts index fe7b34e47..3ce349a8f 100644 --- a/src/hooks/pages/profile/useRenew/useRenew.ts +++ b/src/hooks/pages/profile/useRenew/useRenew.ts @@ -21,6 +21,7 @@ export const calculateRenewState = ({ connectModalOpen, accountStatus, isAbilitiesLoading, + isRouterReady, name, }: { registrationStatus?: RegistrationStatus @@ -30,11 +31,13 @@ export const calculateRenewState = ({ openConnectModal: ReturnType['openConnectModal'] accountStatus: ReturnType['status'] isAbilitiesLoading: boolean + isRouterReady: boolean name?: string }): RenewStatus => { const isNameRegisteredOrGracePeriod = registrationStatus === 'registered' || registrationStatus === 'gracePeriod' const isRenewActive = + isRouterReady && !isRegistrationStatusLoading && !!name && isNameRegisteredOrGracePeriod && @@ -47,6 +50,19 @@ export const calculateRenewState = ({ return 'idle' } +export const removeRenewParam = ({ + query, +}: { + query: ReturnType['query'] +}): string => { + const searchParams = new URLSearchParams(query as any) + // remove the name param in case the page is a redirect from /profile page + searchParams.delete('name') + searchParams.delete('renew') + const newParams = searchParams.toString() + return newParams ? `?${newParams}` : '' +} + export function useRenew(name: string) { const router = useRouterWithHistory() const { registrationStatus, isLoading: isBasicNameLoading } = useBasicName({ name }) @@ -70,6 +86,7 @@ export function useRenew(name: string) { accountStatus: status, isAbilitiesLoading, name, + isRouterReady: router.isReady, openConnectModal, }) @@ -82,7 +99,8 @@ export function useRenew(name: string) { isSelf: canSelfExtend, seconds: renewSeconds!, }) - router.replace(`/${name}`) + const params = removeRenewParam({ query: router.query }) + router.replace(`/${name}${params}`) }) .with('idle', () => {}) .exhaustive() From 4b32cf594f8124c19be0a3a0ebbcf3531d263665 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Wed, 20 Nov 2024 21:35:06 +0800 Subject: [PATCH 14/20] add flag to track if connect modal has been opened --- src/hooks/pages/profile/useRenew/useRenew.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/hooks/pages/profile/useRenew/useRenew.ts b/src/hooks/pages/profile/useRenew/useRenew.ts index 3ce349a8f..ab15ba377 100644 --- a/src/hooks/pages/profile/useRenew/useRenew.ts +++ b/src/hooks/pages/profile/useRenew/useRenew.ts @@ -1,6 +1,6 @@ import { useConnectModal } from '@rainbow-me/rainbowkit' import { useSearchParams } from 'next/navigation' -import { useEffect } from 'react' +import { useEffect, useState } from 'react' import { match } from 'ts-pattern' import { useAccount } from 'wagmi' @@ -23,6 +23,7 @@ export const calculateRenewState = ({ isAbilitiesLoading, isRouterReady, name, + openedConnectModal, }: { registrationStatus?: RegistrationStatus isRegistrationStatusLoading: boolean @@ -33,6 +34,7 @@ export const calculateRenewState = ({ isAbilitiesLoading: boolean isRouterReady: boolean name?: string + openedConnectModal: boolean }): RenewStatus => { const isNameRegisteredOrGracePeriod = registrationStatus === 'registered' || registrationStatus === 'gracePeriod' @@ -44,7 +46,13 @@ export const calculateRenewState = ({ !!renewSeconds && !connectModalOpen - if (isRenewActive && accountStatus === 'disconnected' && !!openConnectModal) return 'connect-user' + if ( + isRenewActive && + accountStatus === 'disconnected' && + !!openConnectModal && + !openedConnectModal + ) + return 'connect-user' if (isRenewActive && accountStatus === 'connected' && !isAbilitiesLoading) return 'display-extend-names' return 'idle' @@ -69,7 +77,9 @@ export function useRenew(name: string) { const abilities = useAbilities({ name }) const searchParams = useSearchParams() const { status } = useAccount() + const { openConnectModal, connectModalOpen } = useConnectModal() + const [openedConnectModal, setOpenedConnectModal] = useState(connectModalOpen) const { usePreparedDataInput } = useTransactionFlow() const showExtendNamesInput = usePreparedDataInput('ExtendNames') @@ -88,11 +98,15 @@ export function useRenew(name: string) { name, isRouterReady: router.isReady, openConnectModal, + openedConnectModal, }) useEffect(() => { match(renewState) - .with('connect-user', () => openConnectModal?.()) + .with('connect-user', () => { + openConnectModal?.() + setOpenedConnectModal(!!openConnectModal) + }) .with('display-extend-names', () => { showExtendNamesInput(`extend-names-${name}`, { names: [name], From e130f04cb11ea1c5b120c98086ad487178912b37 Mon Sep 17 00:00:00 2001 From: Stanislav Lysak Date: Tue, 3 Dec 2024 10:14:01 +0200 Subject: [PATCH 15/20] string test patch --- src/utils/string.test.ts | 9 +++++---- src/utils/string.ts | 12 +++++++----- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/utils/string.test.ts b/src/utils/string.test.ts index 7c079b315..f59f3d850 100644 --- a/src/utils/string.test.ts +++ b/src/utils/string.test.ts @@ -1,5 +1,6 @@ -import { it, describe, expect } from "vitest"; -import { parseNumericString } from "./string"; +import { describe, expect, it } from 'vitest' + +import { parseNumericString } from './string' describe('parseNumericString', () => { it('should return an integer', () => { @@ -22,7 +23,7 @@ describe('parseNumericString', () => { expect(parseNumericString('-123')).toBe(null) }) - it('should return null for a negative number', () => { + it('should return null for a invalid number', () => { expect(parseNumericString('1a23')).toBe(null) }) -}) \ No newline at end of file +}) diff --git a/src/utils/string.ts b/src/utils/string.ts index c2467d224..86a0e4309 100644 --- a/src/utils/string.ts +++ b/src/utils/string.ts @@ -1,9 +1,11 @@ -export const parseNumericString = (time: string | null): number | null => { - if (!time) return null +export const parseNumericString = (value: string | null): number | null => { + if (!value) return null - if (typeof +time === 'number' && !Number.isNaN(+time)) { - return +time + const parsed = Number(value) + + if (typeof parsed !== 'number' || Number.isNaN(parsed) || parsed < 0) { + return null } - return null + return parseInt(String(parsed)) } From d8d2ee14bc7800fe8e914c1192fb23959db36bd5 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Wed, 4 Dec 2024 00:42:05 +0800 Subject: [PATCH 16/20] add a validation function to handle seconds parameter in ExtendNames --- .../pages/profile/[name]/Profile.tsx | 3 +- .../pages/profile/useRenew/useRenew.test.ts | 170 ++++++++++++++++++ src/hooks/pages/profile/useRenew/useRenew.ts | 15 +- .../input/ExtendNames/ExtendNames-flow.tsx | 7 +- .../utils/validateExtendNamesDuration.test.ts | 41 +++++ .../utils/validateExtendNamesDuration.ts | 17 ++ 6 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 src/hooks/pages/profile/useRenew/useRenew.test.ts create mode 100644 src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.test.ts create mode 100644 src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.ts diff --git a/src/components/pages/profile/[name]/Profile.tsx b/src/components/pages/profile/[name]/Profile.tsx index a34786aa3..5f67940be 100644 --- a/src/components/pages/profile/[name]/Profile.tsx +++ b/src/components/pages/profile/[name]/Profile.tsx @@ -182,7 +182,6 @@ const ProfileContent = ({ isSelf, isLoading: parentIsLoading, name }: Props) => const abilities = useAbilities({ name: normalisedName }) - useRenew(normalisedName) // hook for redirecting to the correct profile url // profile.decryptedName fetches labels from NW/subgraph // normalisedName fetches labels from localStorage @@ -203,6 +202,8 @@ const ProfileContent = ({ isSelf, isLoading: parentIsLoading, name }: Props) => // } // }, [name, router, transactions]) + useRenew(normalisedName) + const infoBanner = useMemo(() => { if ( registrationStatus !== 'gracePeriod' && diff --git a/src/hooks/pages/profile/useRenew/useRenew.test.ts b/src/hooks/pages/profile/useRenew/useRenew.test.ts new file mode 100644 index 000000000..c46dd40fc --- /dev/null +++ b/src/hooks/pages/profile/useRenew/useRenew.test.ts @@ -0,0 +1,170 @@ +import { it, expect, describe} from "vitest"; + +import { calculateRenewState } from "./useRenew"; + +describe('calculateRenewState', () => { + it('should return connect-user if accountStatus is disconnected', () => { + expect(calculateRenewState({ + registrationStatus: 'gracePeriod', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'disconnected', + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {} + })).toBe('connect-user') + }) + + it('should return connect-user if accountStatus is connected', () => { + expect(calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected', + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {} + })).toBe('display-extend-names') + }) + + it('should return idle if registration status is available', () => { + expect(calculateRenewState({ + registrationStatus: 'available', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected', + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {} + })).toBe('idle') + }) + + it('should return idle if registration status is loading', () => { + expect(calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: true, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected', + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {} + })).toBe('idle') + }) + + it('should return idle if renewSeconds is null', () => { + expect(calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: null, + connectModalOpen: false, + accountStatus: 'connected', + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {} + })).toBe('idle') + }) + + it('should return idle if connectModalOpen is true', () => { + expect(calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: true, + accountStatus: 'connected', + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {} + })).toBe('idle') + }) + + it('should return idle if abilities is loading', () => { + expect(calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected', + isAbilitiesLoading: true, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {} + })).toBe('idle') + }) + + it('should return idle if isRouterReady is false', () => { + expect(calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected', + isAbilitiesLoading: false, + isRouterReady: false, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {} + })).toBe('idle') + }) + + it('should return idle if name is empty', () => { + expect(calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected', + isAbilitiesLoading: false, + isRouterReady: true, + name: '', + openedConnectModal: false, + openConnectModal: () => {} + })).toBe('idle') + }) + + it('should return idle if openedConnectModal is true', () => { + expect(calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected', + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: true, + openConnectModal: () => {} + })).toBe('idle') + }) + + it('should return idle if openConnectModal is undefined and accountStatus is disconnected', () => { + expect(calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'disconnected', + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: undefined + })).toBe('idle') + }) +}) \ No newline at end of file diff --git a/src/hooks/pages/profile/useRenew/useRenew.ts b/src/hooks/pages/profile/useRenew/useRenew.ts index ab15ba377..0c9802afe 100644 --- a/src/hooks/pages/profile/useRenew/useRenew.ts +++ b/src/hooks/pages/profile/useRenew/useRenew.ts @@ -7,9 +7,9 @@ import { useAccount } from 'wagmi' import { useAbilities } from '@app/hooks/abilities/useAbilities' import { useBasicName } from '@app/hooks/useBasicName' import { useRouterWithHistory } from '@app/hooks/useRouterWithHistory' +import { validateExtendNamesDuration } from '@app/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration' import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' import { RegistrationStatus } from '@app/utils/registrationStatus' -import { parseNumericString } from '@app/utils/string' type RenewStatus = 'connect-user' | 'display-extend-names' | 'idle' @@ -44,15 +44,10 @@ export const calculateRenewState = ({ !!name && isNameRegisteredOrGracePeriod && !!renewSeconds && - !connectModalOpen - - if ( - isRenewActive && - accountStatus === 'disconnected' && - !!openConnectModal && + !connectModalOpen && !openedConnectModal - ) - return 'connect-user' + + if (isRenewActive && accountStatus === 'disconnected' && !!openConnectModal) return 'connect-user' if (isRenewActive && accountStatus === 'connected' && !isAbilitiesLoading) return 'display-extend-names' return 'idle' @@ -86,7 +81,7 @@ export function useRenew(name: string) { const { data: { canSelfExtend } = {}, isLoading: isAbilitiesLoading } = abilities - const renewSeconds = parseNumericString(searchParams.get('renew')) + const renewSeconds = validateExtendNamesDuration({ duration: searchParams.get('renew') }) const renewState = calculateRenewState({ registrationStatus, diff --git a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx index 22d2d61b2..97164798f 100644 --- a/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx +++ b/src/transaction-flow/input/ExtendNames/ExtendNames-flow.tsx @@ -30,6 +30,7 @@ import { deriveYearlyFee, formatDurationOfDates } from '@app/utils/utils' import { ShortExpiry } from '../../../components/@atoms/ExpiryComponents/ExpiryComponents' import GasDisplay from '../../../components/@atoms/GasDisplay' import { SearchViewLoadingView } from '../SendName/views/SearchView/views/SearchViewLoadingView' +import { validateExtendNamesDuration } from './utils/validateExtendNamesDuration' type View = 'name-list' | 'no-ownership-warning' | 'registration' @@ -172,13 +173,15 @@ export type Props = { const minSeconds = ONE_DAY const ExtendNames = ({ - data: { seconds: defaultSeconds = ONE_YEAR, names, isSelf }, + data: { seconds: defaultSeconds, names, isSelf }, dispatch, onDismiss, }: Props) => { const { t } = useTranslation(['transactionFlow', 'common']) - const [seconds, setSeconds] = useState(defaultSeconds) + const [seconds, setSeconds] = useState( + validateExtendNamesDuration({ duration: defaultSeconds ?? ONE_YEAR })!, + ) const years = secondsToYears(seconds) const [durationType, setDurationType] = useState<'years' | 'date'>('years') diff --git a/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.test.ts b/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.test.ts new file mode 100644 index 000000000..a960f91d5 --- /dev/null +++ b/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.test.ts @@ -0,0 +1,41 @@ +import { it, describe, expect } from "vitest"; +import { validateExtendNamesDuration } from "./validateExtendNamesDuration"; +import { ONE_DAY, ONE_YEAR } from "@app/utils/time"; + +describe('validateExtendNamesDuration', () => { + it('should return an integer', () => { + expect(validateExtendNamesDuration({ duration: '90000'})).toBe(90000) + }) + + it('should return an integer for a decimal', () => { + expect(validateExtendNamesDuration({duration: '90000.123'})).toBe(90000) + }) + + it('should return minimum duration for a number less than the minimum', () => { + expect(validateExtendNamesDuration({duration: '0'})).toBe(ONE_DAY) + }) + + it('should return null duration for null', () => { + expect(validateExtendNamesDuration({duration: null})).toBe(null) + }) + + it('should return default duration for undefined', () => { + expect(validateExtendNamesDuration({duration: undefined})).toBe(ONE_YEAR) + }) + + it('should return default for a string', () => { + expect(validateExtendNamesDuration({duration: 'abc'})).toBe(ONE_YEAR) + }) + + it('should return default for an empty string', () => { + expect(validateExtendNamesDuration({ duration: ''})).toBe(ONE_YEAR) + }) + + it('should return default for a negative number', () => { + expect(validateExtendNamesDuration({duration: '-123'})).toBe(ONE_YEAR) + }) + + it('should return default for an object ', () => { + expect(validateExtendNamesDuration({ duration: {}})).toBe(ONE_YEAR) + }) +}) \ No newline at end of file diff --git a/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.ts b/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.ts new file mode 100644 index 000000000..f6fd0ce6e --- /dev/null +++ b/src/transaction-flow/input/ExtendNames/utils/validateExtendNamesDuration.ts @@ -0,0 +1,17 @@ +import { ONE_DAY, ONE_YEAR } from '@app/utils/time' + +export const validateExtendNamesDuration = ({ + duration, + minDuration = ONE_DAY, + defaultDuration = ONE_YEAR, +}: { + duration?: unknown + minDuration?: number + defaultDuration?: number +}): number | null => { + if (duration === null) return null + const parsedDuration = parseInt(duration as string, 10) + if (Number.isNaN(parsedDuration) || parsedDuration < 0) return defaultDuration + if (parsedDuration < minDuration) return minDuration + return parsedDuration +} From 0ff1c8bf2ebfc34f27cb9ac69f6bc67c6f65de00 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Wed, 4 Dec 2024 01:05:22 +0800 Subject: [PATCH 17/20] remove unneeded files which have been transferred to new funciton --- src/utils/string.test.ts | 29 ----------------------------- src/utils/string.ts | 11 ----------- 2 files changed, 40 deletions(-) delete mode 100644 src/utils/string.test.ts delete mode 100644 src/utils/string.ts diff --git a/src/utils/string.test.ts b/src/utils/string.test.ts deleted file mode 100644 index f59f3d850..000000000 --- a/src/utils/string.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from 'vitest' - -import { parseNumericString } from './string' - -describe('parseNumericString', () => { - it('should return an integer', () => { - expect(parseNumericString('123')).toBe(123) - }) - - it('should return an integer for a decimal', () => { - expect(parseNumericString('123.123')).toBe(123) - }) - - it('should return null for a string', () => { - expect(parseNumericString('abc')).toBe(null) - }) - - it('should return null for an empty string', () => { - expect(parseNumericString('')).toBe(null) - }) - - it('should return null for a negative number', () => { - expect(parseNumericString('-123')).toBe(null) - }) - - it('should return null for a invalid number', () => { - expect(parseNumericString('1a23')).toBe(null) - }) -}) diff --git a/src/utils/string.ts b/src/utils/string.ts deleted file mode 100644 index 86a0e4309..000000000 --- a/src/utils/string.ts +++ /dev/null @@ -1,11 +0,0 @@ -export const parseNumericString = (value: string | null): number | null => { - if (!value) return null - - const parsed = Number(value) - - if (typeof parsed !== 'number' || Number.isNaN(parsed) || parsed < 0) { - return null - } - - return parseInt(String(parsed)) -} From 4f0bdef39d1c35bd9b6020d95c0a1b69230b3123 Mon Sep 17 00:00:00 2001 From: storywithoutend Date: Wed, 4 Dec 2024 10:17:15 +0800 Subject: [PATCH 18/20] fix unit test title --- src/hooks/pages/profile/useRenew/useRenew.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/pages/profile/useRenew/useRenew.test.ts b/src/hooks/pages/profile/useRenew/useRenew.test.ts index c46dd40fc..50eca3e91 100644 --- a/src/hooks/pages/profile/useRenew/useRenew.test.ts +++ b/src/hooks/pages/profile/useRenew/useRenew.test.ts @@ -18,7 +18,7 @@ describe('calculateRenewState', () => { })).toBe('connect-user') }) - it('should return connect-user if accountStatus is connected', () => { + it('should return display-extend-names if accountStatus is connected', () => { expect(calculateRenewState({ registrationStatus: 'registered', isRegistrationStatusLoading: false, From 61b920d669f1e33084db671bb54366782fab923d Mon Sep 17 00:00:00 2001 From: sugh01 <19183308+sugh01@users.noreply.github.com> Date: Fri, 6 Dec 2024 06:29:32 +0100 Subject: [PATCH 19/20] add more unit tests --- .../pages/profile/useRenew/useRenew.test.ts | 795 +++++++++++++++--- 1 file changed, 667 insertions(+), 128 deletions(-) diff --git a/src/hooks/pages/profile/useRenew/useRenew.test.ts b/src/hooks/pages/profile/useRenew/useRenew.test.ts index 50eca3e91..fd0522b17 100644 --- a/src/hooks/pages/profile/useRenew/useRenew.test.ts +++ b/src/hooks/pages/profile/useRenew/useRenew.test.ts @@ -1,170 +1,709 @@ -import { it, expect, describe} from "vitest"; +import type { ParsedUrlQuery } from 'querystring' +import { mockFunction, renderHook, screen } from '@app/test-utils' -import { calculateRenewState } from "./useRenew"; +import { useConnectModal } from '@rainbow-me/rainbowkit' +import mockRouter from 'next-router-mock' +import { useSearchParams } from 'next/navigation' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useAccount, type UseAccountReturnType } from 'wagmi' + +import { sc } from '@ensdomains/address-encoder/coins' + +import { useAbilities } from '@app/hooks/abilities/useAbilities' +import { useBasicName } from '@app/hooks/useBasicName' +import { useTransactionFlow } from '@app/transaction-flow/TransactionFlowProvider' +import type { RegistrationStatus } from '@app/utils/registrationStatus' + +import { calculateRenewState, removeRenewParam, useRenew } from './useRenew' + +vi.mock('next/router', async () => await vi.importActual('next-router-mock')) +vi.mock('@app/hooks/useBasicName') +vi.mock('@app/hooks/abilities/useAbilities') +vi.mock('@rainbow-me/rainbowkit') +vi.mock('wagmi') +vi.mock('@app/transaction-flow/TransactionFlowProvider') +vi.mock('next/navigation') + +const mockUseBasicName = mockFunction(useBasicName) +const mockUseAbilities = mockFunction(useAbilities) +const mockUseConnectModal = mockFunction(useConnectModal) +const mockUseAccount = mockFunction(useAccount) +const mockUseTransactionFlow = mockFunction(useTransactionFlow) +const mockUseSearchParams = mockFunction(useSearchParams) describe('calculateRenewState', () => { it('should return connect-user if accountStatus is disconnected', () => { - expect(calculateRenewState({ - registrationStatus: 'gracePeriod', - isRegistrationStatusLoading: false, - renewSeconds: 123, - connectModalOpen: false, - accountStatus: 'disconnected', - isAbilitiesLoading: false, - isRouterReady: true, - name: 'name', - openedConnectModal: false, - openConnectModal: () => {} - })).toBe('connect-user') + expect( + calculateRenewState({ + registrationStatus: 'gracePeriod', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'disconnected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('connect-user') }) it('should return display-extend-names if accountStatus is connected', () => { - expect(calculateRenewState({ - registrationStatus: 'registered', - isRegistrationStatusLoading: false, - renewSeconds: 123, - connectModalOpen: false, - accountStatus: 'connected', - isAbilitiesLoading: false, - isRouterReady: true, - name: 'name', - openedConnectModal: false, - openConnectModal: () => {} - })).toBe('display-extend-names') + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('display-extend-names') }) it('should return idle if registration status is available', () => { - expect(calculateRenewState({ - registrationStatus: 'available', - isRegistrationStatusLoading: false, - renewSeconds: 123, - connectModalOpen: false, - accountStatus: 'connected', - isAbilitiesLoading: false, - isRouterReady: true, - name: 'name', - openedConnectModal: false, - openConnectModal: () => {} - })).toBe('idle') + expect( + calculateRenewState({ + registrationStatus: 'available', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') }) it('should return idle if registration status is loading', () => { - expect(calculateRenewState({ - registrationStatus: 'registered', - isRegistrationStatusLoading: true, - renewSeconds: 123, - connectModalOpen: false, - accountStatus: 'connected', - isAbilitiesLoading: false, - isRouterReady: true, - name: 'name', - openedConnectModal: false, - openConnectModal: () => {} - })).toBe('idle') + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: true, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') }) it('should return idle if renewSeconds is null', () => { - expect(calculateRenewState({ - registrationStatus: 'registered', - isRegistrationStatusLoading: false, - renewSeconds: null, - connectModalOpen: false, - accountStatus: 'connected', - isAbilitiesLoading: false, - isRouterReady: true, - name: 'name', - openedConnectModal: false, - openConnectModal: () => {} - })).toBe('idle') + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: null, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') }) it('should return idle if connectModalOpen is true', () => { - expect(calculateRenewState({ - registrationStatus: 'registered', - isRegistrationStatusLoading: false, - renewSeconds: 123, - connectModalOpen: true, - accountStatus: 'connected', - isAbilitiesLoading: false, - isRouterReady: true, - name: 'name', - openedConnectModal: false, - openConnectModal: () => {} - })).toBe('idle') + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: true, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') }) it('should return idle if abilities is loading', () => { - expect(calculateRenewState({ - registrationStatus: 'registered', - isRegistrationStatusLoading: false, - renewSeconds: 123, - connectModalOpen: false, - accountStatus: 'connected', - isAbilitiesLoading: true, - isRouterReady: true, - name: 'name', - openedConnectModal: false, - openConnectModal: () => {} - })).toBe('idle') + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: true, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') }) it('should return idle if isRouterReady is false', () => { - expect(calculateRenewState({ - registrationStatus: 'registered', - isRegistrationStatusLoading: false, - renewSeconds: 123, - connectModalOpen: false, - accountStatus: 'connected', - isAbilitiesLoading: false, - isRouterReady: false, - name: 'name', - openedConnectModal: false, - openConnectModal: () => {} - })).toBe('idle') + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: false, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') }) it('should return idle if name is empty', () => { - expect(calculateRenewState({ - registrationStatus: 'registered', - isRegistrationStatusLoading: false, - renewSeconds: 123, - connectModalOpen: false, - accountStatus: 'connected', - isAbilitiesLoading: false, - isRouterReady: true, - name: '', - openedConnectModal: false, - openConnectModal: () => {} - })).toBe('idle') + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: '', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') }) it('should return idle if openedConnectModal is true', () => { - expect(calculateRenewState({ - registrationStatus: 'registered', - isRegistrationStatusLoading: false, - renewSeconds: 123, - connectModalOpen: false, - accountStatus: 'connected', - isAbilitiesLoading: false, - isRouterReady: true, - name: 'name', - openedConnectModal: true, - openConnectModal: () => {} - })).toBe('idle') + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: true, + openConnectModal: () => {}, + }), + ).toBe('idle') }) it('should return idle if openConnectModal is undefined and accountStatus is disconnected', () => { - expect(calculateRenewState({ - registrationStatus: 'registered', + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'disconnected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: undefined, + }), + ).toBe('idle') + }) + + it('should return idle if registration status is neither registered nor gracePeriod', () => { + expect( + calculateRenewState({ + registrationStatus: 'premium', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return display-extend-names when all conditions are met for connected user', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('display-extend-names') + }) + + it('should return idle when isRenewActive is false due to missing renewSeconds', () => { + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: null, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('idle') + }) + + it('should return connect-user when user is disconnected and openConnectModal is available', () => { + const openConnectModal = () => {} + expect( + calculateRenewState({ + registrationStatus: 'registered', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'disconnected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal, + }), + ).toBe('connect-user') + }) + + it('should return display-extend-names when name is in grace period and user is connected', () => { + expect( + calculateRenewState({ + registrationStatus: 'gracePeriod', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'connected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal: () => {}, + }), + ).toBe('display-extend-names') + }) + + it('should return connect-user when name is in grace period but user is disconnected', () => { + const openConnectModal = () => {} + expect( + calculateRenewState({ + registrationStatus: 'gracePeriod', + isRegistrationStatusLoading: false, + renewSeconds: 123, + connectModalOpen: false, + accountStatus: 'disconnected' as UseAccountReturnType['status'], + isAbilitiesLoading: false, + isRouterReady: true, + name: 'name', + openedConnectModal: false, + openConnectModal, + }), + ).toBe('connect-user') + }) + + describe('registration status tests', () => { + const baseProps = { isRegistrationStatusLoading: false, renewSeconds: 123, connectModalOpen: false, - accountStatus: 'disconnected', + accountStatus: 'connected' as UseAccountReturnType['status'], isAbilitiesLoading: false, isRouterReady: true, name: 'name', openedConnectModal: false, - openConnectModal: undefined - })).toBe('idle') + openConnectModal: () => {}, + } + + const testCases: { + status: RegistrationStatus + expected: 'idle' | 'display-extend-names' | 'connect-user' + }[] = [ + { status: 'invalid', expected: 'idle' }, + { status: 'short', expected: 'idle' }, + { status: 'imported', expected: 'idle' }, + { status: 'owned', expected: 'idle' }, + { status: 'notImported', expected: 'idle' }, + { status: 'notOwned', expected: 'idle' }, + { status: 'unsupportedTLD', expected: 'idle' }, + { status: 'offChain', expected: 'idle' }, + ] + + testCases.forEach(({ status, expected }) => { + it(`should return ${expected} for registration status ${status}`, () => { + expect( + calculateRenewState({ + ...baseProps, + registrationStatus: status, + }), + ).toBe(expected) + }) + }) + }) +}) + +describe('removeRenewParam', () => { + it('should remove both name and renew params while preserving others', () => { + // URL: ?name=test.eth&renew=123&other=value -> ?other=value + expect( + removeRenewParam({ + query: { + name: 'test.eth', + renew: '123', + other: 'value', + } as ParsedUrlQuery, + }), + ).toBe('?other=value') + }) + + it('should handle multiple values for other params', () => { + // URL: ?name=test.eth&other=value1&other=value2&renew=123 -> ?other=value1&other=value2 + expect( + removeRenewParam({ + query: { + name: 'test.eth', + other: ['value1', 'value2'], + renew: '123', + } as ParsedUrlQuery, + }), + ).toBe('?other=value1%2Cvalue2') + }) + + it('should preserve order of remaining params', () => { + // URL: ?z=last&name=test.eth&a=first&renew=123&m=middle -> ?z=last&a=first&m=middle + expect( + removeRenewParam({ + query: { + z: 'last', + name: 'test.eth', + a: 'first', + renew: '123', + m: 'middle', + } as ParsedUrlQuery, + }), + ).toBe('?z=last&a=first&m=middle') + }) + + it('should handle encoded characters in params', () => { + // URL: ?name=test%20name.eth¶m=special%20value&renew=123 -> ?param=special%20value + expect( + removeRenewParam({ + query: { + name: 'test name.eth', + param: 'special value', + renew: '123', + } as ParsedUrlQuery, + }), + ).toBe('?param=special+value') + }) + + it('should return empty string when all params are removed', () => { + // URL: ?name=test.eth&renew=123 -> '' + expect( + removeRenewParam({ + query: { + name: 'test.eth', + renew: '123', + } as ParsedUrlQuery, + }), + ).toBe('') + }) + + it('should handle empty query object', () => { + // URL: '' -> '' + expect( + removeRenewParam({ + query: {} as ParsedUrlQuery, + }), + ).toBe('') + }) + + it('should handle query with only name param', () => { + // URL: ?name=test.eth -> '' + expect( + removeRenewParam({ + query: { + name: 'test.eth', + } as ParsedUrlQuery, + }), + ).toBe('') + }) + + it('should handle query with only renew param', () => { + // URL: ?renew=123 -> '' + expect( + removeRenewParam({ + query: { + renew: '123', + } as ParsedUrlQuery, + }), + ).toBe('') + }) + + it('should preserve empty values in other params', () => { + // URL: ?name=test.eth&empty=&renew=123 -> ?empty= + expect( + removeRenewParam({ + query: { + name: 'test.eth', + empty: '', + renew: '123', + } as ParsedUrlQuery, + }), + ).toBe('?empty=') + }) +}) + +describe('useRenew', () => { + const mockShowExtendNamesInput = vi.fn() + + beforeEach(() => { + mockUseBasicName.mockReturnValue({ + registrationStatus: 'registered', + isLoading: false, + }) + + mockUseAbilities.mockReturnValue({ + data: { canSelfExtend: true }, + isLoading: false, + }) + + mockUseAccount.mockReturnValue({ + status: 'connected', + }) + + mockUseConnectModal.mockReturnValue({ + connectModalOpen: false, + openConnectModal: undefined, + }) + + mockUseTransactionFlow.mockReturnValue({ + usePreparedDataInput: () => mockShowExtendNamesInput, + }) + + mockUseSearchParams.mockReturnValue({ + get: (key: string) => (key === 'renew' ? '123' : null), + }) + + mockRouter.setCurrentUrl('/test.eth') + }) + + afterEach(() => { + vi.clearAllMocks() + mockRouter.setCurrentUrl('/') + }) + + it('should handle URL changes', () => { + mockRouter.push('/test.eth?renew=86400') + + mockUseSearchParams.mockReturnValue({ + get: (key: string) => (key === 'renew' ? '86400' : null), + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: 86400, + }) + + mockRouter.push('/test.eth?renew=94608000') + + mockUseAbilities.mockReturnValue({ + data: { canSelfExtend: true }, + isLoading: false, + }) + + mockUseSearchParams.mockReturnValue({ + get: (key: string) => (key === 'renew' ? '94608000' : null), + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: 94608000, + }) + }) + + it('should show extend names input for registered names', () => { + mockRouter.push('/test.eth?renew=123') + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: 86400, + }) + expect(mockRouter.asPath).toBe('/test.eth') + }) + + it('should show extend names input for registered names with default duration', () => { + mockRouter.push('/test.eth?renew') + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: 86400, + }) + expect(mockRouter.asPath).toBe('/test.eth') + }) + + it('should show extend names input for registered names with large duration', () => { + const largeDuration = 31536000000 // very large number + mockRouter.push(`/test.eth?renew=${largeDuration}`) + mockUseSearchParams.mockReturnValue({ + get: (key: string) => (key === 'renew' ? largeDuration.toString() : null), + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: largeDuration, + }) + expect(mockRouter.asPath).toBe('/test.eth') + }) + + it('should show extend names input for names in grace period', () => { + mockRouter.push('/test.eth?renew=123') + mockUseBasicName.mockReturnValue({ + registrationStatus: 'gracePeriod', + isLoading: false, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: true, + seconds: 86400, + }) + }) + + it('should open connect modal for disconnected users', () => { + mockRouter.push('/test.eth?renew=123') + const mockOpenConnectModal = vi.fn() + mockUseAccount.mockReturnValue({ status: 'disconnected' }) + mockUseConnectModal.mockReturnValue({ + openConnectModal: mockOpenConnectModal, + connectModalOpen: false, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockOpenConnectModal).toHaveBeenCalled() + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should do nothing when name is not registered or in grace period', () => { + mockRouter.push('/test.eth?renew=123') + mockUseBasicName.mockReturnValue({ + registrationStatus: 'available', + isLoading: false, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should do nothing when registration status is loading', () => { + mockRouter.push('/test.eth?renew=123') + mockUseBasicName.mockReturnValue({ + registrationStatus: 'registered', + isLoading: true, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should handle non-self-extendable names', () => { + mockRouter.push('/test.eth?renew=123') + mockUseAbilities.mockReturnValue({ + data: { canSelfExtend: false }, + isLoading: false, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).toHaveBeenCalledWith('extend-names-test.eth', { + names: ['test.eth'], + isSelf: false, + seconds: 86400, + }) + }) + + it('should do nothing when abilities are loading', () => { + mockRouter.push('/test.eth?renew=123') + mockUseAbilities.mockReturnValue({ + data: { canSelfExtend: true }, + isLoading: true, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should do nothing when router is not ready', () => { + mockRouter.push('/test.eth?renew=123') + mockRouter.isReady = false + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should do nothing when connect modal is open', () => { + mockRouter.push('/test.eth?renew=123') + mockUseConnectModal.mockReturnValue({ + openConnectModal: vi.fn(), + connectModalOpen: true, + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() + }) + + it('should handle invalid duration in URL', () => { + mockRouter.push('/test.eth?renew=invalid') + mockUseSearchParams.mockReturnValue({ + get: (key: string) => (key === 'renew' ? 'invalid' : null), + }) + + renderHook(() => useRenew('test.eth')) + + expect(mockShowExtendNamesInput).not.toHaveBeenCalled() }) -}) \ No newline at end of file +}) From 6a6da55dc0d59df7dc928e94838da684b71821ec Mon Sep 17 00:00:00 2001 From: sugh01 <19183308+sugh01@users.noreply.github.com> Date: Sun, 8 Dec 2024 21:09:58 +0100 Subject: [PATCH 20/20] playwright tests --- e2e/specs/stateless/extendNames.spec.ts | 49 +++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/e2e/specs/stateless/extendNames.spec.ts b/e2e/specs/stateless/extendNames.spec.ts index 6103717c8..b36103402 100644 --- a/e2e/specs/stateless/extendNames.spec.ts +++ b/e2e/specs/stateless/extendNames.spec.ts @@ -678,3 +678,52 @@ test('renew deep link should redirect to registration when not logged in', async await page.goto(`/${name}?renew=123`) await expect(page.getByRole('heading', { name: `Register ${name}` })).toBeVisible() }) + +test('should handle URL-based renew parameter', async ({ page, login, makeName }) => { + const name = await makeName({ + label: 'legacy', + type: 'legacy', + owner: 'user', + }) + + await test.step('should handle large duration', async () => { + await page.goto(`/${name}?renew=315360000`) // 10 years + await login.connect() + await expect(page.getByText('10 years extension', { exact: true })).toBeVisible() + }) +}) + +test('should handle URL-based renew for names in grace period', async ({ + page, + login, + makeName, +}) => { + const name = await makeName({ + label: 'legacy', + type: 'legacy', + owner: 'user', + duration: -24 * 60 * 60, + }) + + await test.step('should allow extend in grace period', async () => { + await page.goto(`/${name}?renew=94608000`) // 3 years + await login.connect() + + await expect(page.getByText(`${name} has expired`)).toBeVisible() + await expect(page.getByText(`You do not own ${name}`)).toBeVisible() + await page.getByTestId('extend-names-confirm').click() + + await expect(page.getByText('3 years extension', { exact: true })).toBeVisible() + }) +}) + +test('should handle URL-based renew for disconnected users', async ({ page, makeName }) => { + const name = await makeName({ + label: 'legacy', + type: 'legacy', + owner: 'user', + }) + + await page.goto(`/${name}?renew=94608000`) + await expect(page.getByText('Connect a wallet')).toBeVisible() +})