Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

show emails modal after member creation #4579

Merged
merged 5 commits into from
Oct 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 65 additions & 22 deletions packages/ui/src/app/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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,
},
Expand All @@ -130,7 +130,7 @@ export default {
queries: [
{
query: GetMemberDocument,
data: { membershipByUniqueInput: { ...bob, ...MEMBER_DATA, invitees: [] } },
data: { membershipByUniqueInput: { ...bob, ...NEW_MEMBER_DATA, invitees: [] } },
},
{
query: GetBackendMemberExistsDocument,
Expand Down Expand Up @@ -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()
})
Expand All @@ -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/))
}

Expand Down Expand Up @@ -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: '[email protected]' }],
}),
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'))
})
},
}
Expand Down
32 changes: 24 additions & 8 deletions packages/ui/src/common/hooks/useQueryNodeTransactionStatus.ts
Original file line number Diff line number Diff line change
@@ -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<TransactionStatus>('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
}
8 changes: 6 additions & 2 deletions packages/ui/src/common/hooks/useSignAndSendTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -40,15 +41,18 @@ export const useSignAndSendTransaction = ({
const [blockHash, setBlockHash] = useState<Hash | string | undefined>(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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<OnBoardingStatus>()
Expand Down Expand Up @@ -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' })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<EmailSubscriptionModalCall>({
modal: 'EmailSubscriptionModal',
data: {},
})
}
}, [showSubscriptionModal])
}, [showSubscriptionModal, modal])

const history = useHistory()
const routeQuery = useRouteQuery()
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 })
Expand All @@ -41,7 +50,7 @@ export const BuyMembershipModal = () => {
)
}

if (state.matches('success')) {
if (isSuccessful) {
const { form, memberId } = state.context
return <BuyMembershipSuccessModal onClose={hideModal} member={form} memberId={memberId?.toString()} />
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -20,28 +20,31 @@ interface Props {
}

export const BuyMembershipSuccessModal = ({ onClose, member, memberId }: Props) => {
const { showModal } = useModal()
const viewMember = () => {
onClose()
const { setActive: setActiveMember, members } = useMyMemberships()

if (memberId) {
showModal<MemberModalCall>({ 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 (
<Modal modalSize="m" modalHeight="s" onClose={onClose}>
<ModalHeader onClick={onClose} title="Success" icon={<SuccessIcon />} />
<ModalHeader onClick={handleClose} title="Success" icon={<SuccessIcon />} />
<ModalBody>
<TextMedium>You have just successfully created a new membership</TextMedium>
<MemberRow>
<MemberInfo member={member as unknown as Member} skipModal />
</MemberRow>
</ModalBody>
<ModalFooter>
<ButtonPrimary size="medium" disabled={!memberId} onClick={viewMember}>
View my profile
</ButtonPrimary>
<ButtonGhost size="medium" disabled={!memberId} onClick={handleClose}>
Done
</ButtonGhost>
</ModalFooter>
</Modal>
)
Expand Down
3 changes: 3 additions & 0 deletions packages/ui/src/mocks/providers/api.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ export const MockApiProvider: FC<MockApiProps> = ({ children, chain }) => {
// Common mocks:
const rpcChain = {
getBlockHash: createType('BlockHash', BLOCK_HASH),
getHeader: {
number: BLOCK_HEAD,
},
subscribeNewHeads: {
parentHash: BLOCK_HASH,
number: BLOCK_HEAD,
Expand Down
8 changes: 8 additions & 0 deletions packages/ui/test/_mocks/transactions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ export const stubApi = () => {
from([createType('BlockHash', '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY')])
})

set(api, 'api.rpc.chain.getHeader', () =>
from([
{
number: createType('BlockNumber', 1337),
},
])
)

return api
}

Expand Down