diff --git a/packages/ui/src/app/App.stories.tsx b/packages/ui/src/app/App.stories.tsx index d155d041b1..15640aaec3 100644 --- a/packages/ui/src/app/App.stories.tsx +++ b/packages/ui/src/app/App.stories.tsx @@ -46,8 +46,8 @@ const alice = member('alice') const bob = member('bob') const charlie = member('charlie') -const MEMBER_DATA = { - id: '12', +const NEW_MEMBER_DATA = { + id: alice.id, // we set this to alice's ID so that after member is created, member with same ID can be found in MembershipContext handle: 'realbobbybob', metadata: { name: 'BobbyBob', @@ -118,7 +118,7 @@ export default { members: { buyMembership: { event: 'MembershipBought', - data: [MEMBER_DATA.id], + data: [NEW_MEMBER_DATA.id], onSend: args.onBuyMembership, failure: parameters.txFailure, }, @@ -130,7 +130,7 @@ export default { queries: [ { query: GetMemberDocument, - data: { membershipByUniqueInput: { ...bob, ...MEMBER_DATA, invitees: [] } }, + data: { membershipByUniqueInput: { ...bob, ...NEW_MEMBER_DATA, invitees: [] } }, }, { query: GetBackendMemberExistsDocument, @@ -314,10 +314,10 @@ export const FaucetMembership: Story = { expect(modal.getByText('Please fill in all the details below.')) // Check that the CAPTCHA blocks the next step - await userEvent.type(modal.getByLabelText('Member Name'), MEMBER_DATA.metadata.name) - await userEvent.type(modal.getByLabelText('Membership handle'), MEMBER_DATA.handle) - await userEvent.type(modal.getByLabelText('About member'), MEMBER_DATA.metadata.about) - await userEvent.type(modal.getByLabelText('Member Avatar'), MEMBER_DATA.metadata.avatar.avatarUri) + await userEvent.type(modal.getByLabelText('Member Name'), NEW_MEMBER_DATA.metadata.name) + await userEvent.type(modal.getByLabelText('Membership handle'), NEW_MEMBER_DATA.handle) + await userEvent.type(modal.getByLabelText('About member'), NEW_MEMBER_DATA.metadata.about) + await userEvent.type(modal.getByLabelText('Member Avatar'), NEW_MEMBER_DATA.metadata.avatar.avatarUri) await userEvent.click(modal.getByLabelText(/^I agree to the/)) expect(getButtonByText(modal, 'Create a Membership')).toBeDisabled() }) @@ -330,10 +330,10 @@ export const FaucetMembership: Story = { const fillMembershipForm = async (modal: Container) => { await selectFromDropdown(modal, 'Root account', 'alice') await selectFromDropdown(modal, 'Controller account', 'bob') - await userEvent.type(modal.getByLabelText('Member Name'), MEMBER_DATA.metadata.name) - await userEvent.type(modal.getByLabelText('Membership handle'), MEMBER_DATA.handle) - await userEvent.type(modal.getByLabelText('About member'), MEMBER_DATA.metadata.about) - await userEvent.type(modal.getByLabelText('Member Avatar'), MEMBER_DATA.metadata.avatar.avatarUri) + await userEvent.type(modal.getByLabelText('Member Name'), NEW_MEMBER_DATA.metadata.name) + await userEvent.type(modal.getByLabelText('Membership handle'), NEW_MEMBER_DATA.handle) + await userEvent.type(modal.getByLabelText('About member'), NEW_MEMBER_DATA.metadata.about) + await userEvent.type(modal.getByLabelText('Member Avatar'), NEW_MEMBER_DATA.metadata.avatar.avatarUri) await userEvent.click(modal.getByLabelText(/^I agree to the/)) } @@ -382,28 +382,71 @@ export const BuyMembershipHappy: Story = { await step('Confirm', async () => { expect(await modal.findByText('Success')) - expect(modal.getByText(MEMBER_DATA.handle)) + expect(modal.getByText(NEW_MEMBER_DATA.handle)) expect(args.onBuyMembership).toHaveBeenCalledWith({ rootAccount: alice.controllerAccount, controllerAccount: bob.controllerAccount, - handle: MEMBER_DATA.handle, + handle: NEW_MEMBER_DATA.handle, metadata: metadataToBytes(MembershipMetadata, { - name: MEMBER_DATA.metadata.name, - about: MEMBER_DATA.metadata.about, - avatarUri: MEMBER_DATA.metadata.avatar.avatarUri, + name: NEW_MEMBER_DATA.metadata.name, + about: NEW_MEMBER_DATA.metadata.about, + avatarUri: NEW_MEMBER_DATA.metadata.avatar.avatarUri, externalResources: [{ type: MembershipMetadata.ExternalResource.ResourceType.EMAIL, value: 'bobby@bob.com' }], }), invitingMemberId: undefined, referrerId: undefined, }) - const viewProfileButton = getButtonByText(modal, 'View my profile') - expect(viewProfileButton).toBeEnabled() - userEvent.click(viewProfileButton) + const doneButton = getButtonByText(modal, 'Done') + expect(doneButton).toBeEnabled() + userEvent.click(doneButton) + }) + }, +} + +// in this test, we are testing whether the email subscription modal is shown after the membership is bought +// there's no easy way to change mocked members mid-story so we start with memberships +// this way the BuyMembershipModal can set active member, which should trigger the email subscription modal +export const BuyMembershipEmailSignup: Story = { + args: { hasMemberships: true, isLoggedIn: false, hasRegisteredEmail: false, hasBeenAskedForEmail: false }, + + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + userEvent.click(getButtonByText(screen, 'Select membership')) + const newMemberButton = screen.getByText('New Member') as HTMLElement + expect(newMemberButton).toBeEnabled() + userEvent.click(newMemberButton) + const createButton = getButtonByText(modal, 'Create a Membership') + await fillMembershipForm(modal) + await waitFor(() => expect(createButton).toBeEnabled()) + userEvent.click(createButton) + userEvent.click(await waitFor(() => getButtonByText(modal, 'Sign and create a member'))) + + await step('Confirm', async () => { + expect(await modal.findByText('Success')) + expect(modal.getByText(NEW_MEMBER_DATA.handle)) + + expect(args.onBuyMembership).toHaveBeenCalledWith({ + rootAccount: alice.controllerAccount, + controllerAccount: bob.controllerAccount, + handle: NEW_MEMBER_DATA.handle, + metadata: metadataToBytes(MembershipMetadata, { + name: NEW_MEMBER_DATA.metadata.name, + about: NEW_MEMBER_DATA.metadata.about, + avatarUri: NEW_MEMBER_DATA.metadata.avatar.avatarUri, + }), + invitingMemberId: undefined, + referrerId: undefined, + }) + + const doneButton = getButtonByText(modal, 'Done') + expect(doneButton).toBeEnabled() + userEvent.click(doneButton) - expect(modal.getByText('Profile')) - expect(modal.getByText(MEMBER_DATA.handle)) + await waitFor(() => modal.findByText('Sign up to email notifications')) }) }, } diff --git a/packages/ui/src/common/hooks/useQueryNodeTransactionStatus.ts b/packages/ui/src/common/hooks/useQueryNodeTransactionStatus.ts index 75be7264d2..d9cb702eb5 100644 --- a/packages/ui/src/common/hooks/useQueryNodeTransactionStatus.ts +++ b/packages/ui/src/common/hooks/useQueryNodeTransactionStatus.ts @@ -1,30 +1,46 @@ import { Hash } from '@polkadot/types/interfaces/runtime' import { useEffect, useState } from 'react' +import { warning } from '../logger' + import { useBlockHash } from './useBlockHash' import { useQueryNodeStateSubscription } from './useQueryNode' type TransactionStatus = 'confirmed' | 'rejected' | 'unknown' -export function useQueryNodeTransactionStatus(isProcessing: boolean, blockHash?: Hash | string, skip?: boolean) { +export function useQueryNodeTransactionStatus( + isProcessing: boolean, + blockHashOrNumber?: Hash | string | number, + skip?: boolean +) { const { queryNodeState } = useQueryNodeStateSubscription({ shouldResubscribe: true, skip }) const [status, setStatus] = useState('unknown') - const queryNodeBlockHash = useBlockHash(queryNodeState?.indexerHead) + const queryNodeIndexerHeadHash = useBlockHash(queryNodeState?.indexerHead) useEffect(() => { - if (!queryNodeState && isProcessing) { + if (isProcessing) { const timeout = setTimeout(() => { + warning('QN sync timeout') setStatus('confirmed') - }, 10_000) + }, 15_000) return () => clearTimeout(timeout) } - }, [!queryNodeState, isProcessing]) + }, [isProcessing]) useEffect(() => { - if (queryNodeState) { - setStatus(blockHash === queryNodeBlockHash ? 'confirmed' : 'rejected') + if (queryNodeState && blockHashOrNumber) { + let isSynced = false + if (typeof blockHashOrNumber === 'number') { + isSynced = parseInt(queryNodeState.lastCompleteBlock) >= blockHashOrNumber + } else { + isSynced = blockHashOrNumber === queryNodeIndexerHeadHash + } + + if (isSynced) { + setStatus('confirmed') + } } - }, [queryNodeState, queryNodeBlockHash]) + }, [queryNodeState, blockHashOrNumber, queryNodeIndexerHeadHash]) return status } diff --git a/packages/ui/src/common/hooks/useSignAndSendTransaction.ts b/packages/ui/src/common/hooks/useSignAndSendTransaction.ts index ff83d7f8cb..81b1398f3c 100644 --- a/packages/ui/src/common/hooks/useSignAndSendTransaction.ts +++ b/packages/ui/src/common/hooks/useSignAndSendTransaction.ts @@ -14,6 +14,7 @@ import { getFeeSpendableBalance } from '@/common/providers/transactionFees/provi import { Address } from '../types' +import { useFirstObservableValue } from './useFirstObservableValue' import { useProcessTransaction } from './useProcessTransaction' import { useQueryNodeTransactionStatus } from './useQueryNodeTransactionStatus' @@ -40,15 +41,18 @@ export const useSignAndSendTransaction = ({ const [blockHash, setBlockHash] = useState(undefined) const apolloClient = useApolloClient() const balance = useBalance(signer) + const { api } = useApi() const { send, paymentInfo, isReady, isProcessing } = useProcessTransaction({ transaction, signer, service, setBlockHash, }) - const queryNodeStatus = useQueryNodeTransactionStatus(isProcessing, blockHash, skipQueryNode) + const blockNumber = useFirstObservableValue(() => { + if (blockHash) return api?.rpc.chain.getHeader(blockHash) + }, [api?.isConnected, blockHash])?.number.toNumber() + const queryNodeStatus = useQueryNodeTransactionStatus(isProcessing, blockNumber || blockHash, skipQueryNode) const { wallet } = useMyAccounts() - const { api } = useApi() const sign = useCallback(() => { if (wallet && api) { diff --git a/packages/ui/src/common/modals/OnBoardingModal/OnBoardingModal.tsx b/packages/ui/src/common/modals/OnBoardingModal/OnBoardingModal.tsx index 76962ab0d4..1b130b3ace 100644 --- a/packages/ui/src/common/modals/OnBoardingModal/OnBoardingModal.tsx +++ b/packages/ui/src/common/modals/OnBoardingModal/OnBoardingModal.tsx @@ -33,8 +33,8 @@ export const OnBoardingModal = () => { const { status: realStatus, membershipAccount, setMembershipAccount, isLoading } = useOnBoarding() const status = useDebounce(realStatus, 50) const [state, send] = useMachine(onBoardingMachine) - const [membershipData, setMembershipData] = useState<{ id: string; blockHash: string }>() - const transactionStatus = useQueryNodeTransactionStatus(!!membershipData?.blockHash, membershipData?.blockHash) + const [membershipData, setMembershipData] = useState<{ id: string; blockHash: string; blockNumber: number }>() + const transactionStatus = useQueryNodeTransactionStatus(!!membershipData, membershipData?.blockNumber) const apolloClient = useApolloClient() const [endpoints] = useNetworkEndpoints() const statusRef = useRef() @@ -86,12 +86,12 @@ export const OnBoardingModal = () => { body: JSON.stringify(membershipData), }) - const { error, memberId, blockHash } = await response.json() + const { error, memberId, blockHash, block } = await response.json() if (error) { send({ type: 'ERROR' }) } else { - setMembershipData({ id: parseInt(memberId, 16).toString(), blockHash: blockHash }) + setMembershipData({ id: memberId, blockHash: blockHash, blockNumber: block }) } } catch (err) { send({ type: 'ERROR' }) diff --git a/packages/ui/src/memberships/components/CurrentMember/CurrentMember.tsx b/packages/ui/src/memberships/components/CurrentMember/CurrentMember.tsx index 2f987a8135..c677842459 100644 --- a/packages/ui/src/memberships/components/CurrentMember/CurrentMember.tsx +++ b/packages/ui/src/memberships/components/CurrentMember/CurrentMember.tsx @@ -21,19 +21,19 @@ import { AddMembershipButton } from '../AddMembershipButton' export const CurrentMember = () => { const { wallet } = useMyAccounts() const { members, hasMembers, active } = useMyMemberships() - const { showModal } = useModal() + const { showModal, modal } = useModal() const { activeMemberSettings, activeMemberExistBackendData } = useNotificationSettings() const showSubscriptionModal = active && activeMemberExistBackendData?.memberExist === false && !activeMemberSettings?.hasBeenAskedForEmail useEffect(() => { - if (!emailVerificationToken && showSubscriptionModal) { + if (!emailVerificationToken && !modal && showSubscriptionModal) { showModal({ modal: 'EmailSubscriptionModal', data: {}, }) } - }, [showSubscriptionModal]) + }, [showSubscriptionModal, modal]) const history = useHistory() const routeQuery = useRouteQuery() diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx index 615a96f227..c73b25f672 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx @@ -1,4 +1,5 @@ -import React from 'react' +import { useApolloClient } from '@apollo/client' +import React, { useEffect } from 'react' import { useApi } from '@/api/hooks/useApi' import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' @@ -17,6 +18,14 @@ export const BuyMembershipModal = () => { const membershipPrice = useFirstObservableValue(() => api?.query.members.membershipPrice(), [api?.isConnected]) const [state, send] = useMachine(buyMembershipMachine) + const apolloClient = useApolloClient() + + const isSuccessful = state.matches('success') + // refetch data after successful member creation + useEffect(() => { + if (!isSuccessful) return + apolloClient.refetchQueries({ include: 'active' }) + }, [isSuccessful, apolloClient]) if (state.matches('prepare')) { const onSubmit = (params: MemberFormFields) => send({ type: 'DONE', form: params }) @@ -41,7 +50,7 @@ export const BuyMembershipModal = () => { ) } - if (state.matches('success')) { + if (isSuccessful) { const { form, memberId } = state.context return } diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSuccessModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSuccessModal.tsx index aff1171b9b..8b2f8a6851 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSuccessModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSuccessModal.tsx @@ -1,14 +1,14 @@ import React from 'react' -import { ButtonPrimary } from '@/common/components/buttons' +import { ButtonGhost } from '@/common/components/buttons' import { SuccessIcon } from '@/common/components/icons' import { Modal, ModalBody, ModalFooter, ModalHeader } from '@/common/components/Modal' import { TextMedium } from '@/common/components/typography' -import { useModal } from '@/common/hooks/useModal' +import { warning } from '@/common/logger' +import { useMyMemberships } from '@/memberships/hooks/useMyMemberships' import { MemberRow } from '@/memberships/modals/components' import { MemberInfo } from '../../components' -import { MemberModalCall } from '../../components/MemberProfile' import { Member } from '../../types' import { MemberFormFields } from './BuyMembershipFormModal' @@ -20,18 +20,21 @@ interface Props { } export const BuyMembershipSuccessModal = ({ onClose, member, memberId }: Props) => { - const { showModal } = useModal() - const viewMember = () => { - onClose() + const { setActive: setActiveMember, members } = useMyMemberships() - if (memberId) { - showModal({ modal: 'Member', data: { id: memberId } }) + const handleClose = () => { + const newMember = members.find((member) => member.id === memberId?.toString()) + if (newMember) { + setActiveMember(newMember) + } else { + warning('Could not find new member', memberId?.toString()) } + onClose() } return ( - } /> + } /> You have just successfully created a new membership @@ -39,9 +42,9 @@ export const BuyMembershipSuccessModal = ({ onClose, member, memberId }: Props) - - View my profile - + + Done + ) diff --git a/packages/ui/src/mocks/providers/api.tsx b/packages/ui/src/mocks/providers/api.tsx index 8bddcc7f27..059ad529de 100644 --- a/packages/ui/src/mocks/providers/api.tsx +++ b/packages/ui/src/mocks/providers/api.tsx @@ -42,6 +42,9 @@ export const MockApiProvider: FC = ({ children, chain }) => { // Common mocks: const rpcChain = { getBlockHash: createType('BlockHash', BLOCK_HASH), + getHeader: { + number: BLOCK_HEAD, + }, subscribeNewHeads: { parentHash: BLOCK_HASH, number: BLOCK_HEAD, diff --git a/packages/ui/test/_mocks/transactions.ts b/packages/ui/test/_mocks/transactions.ts index 3588abe900..aebe969ab3 100644 --- a/packages/ui/test/_mocks/transactions.ts +++ b/packages/ui/test/_mocks/transactions.ts @@ -136,6 +136,14 @@ export const stubApi = () => { from([createType('BlockHash', '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY')]) }) + set(api, 'api.rpc.chain.getHeader', () => + from([ + { + number: createType('BlockNumber', 1337), + }, + ]) + ) + return api }