Skip to content

Commit

Permalink
Create Funding Request Proposal for Multiple Recipients (#4453)
Browse files Browse the repository at this point in the history
* preview multiple receipients modal

* multiple funding request validation

* csv reg exp pattern

* Preview And Validate Modal validation

* lint fix

* Tx fail fix

* suggestions

* lint fix

* side drawer

* csv pattern change and cleanup

* Multiple funding request test

* lint

* Update packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx

Co-authored-by: Theophile Sandoz <theophile.sandoz@gmail.com>

* requested changes

* lint

* disable preview button

* updated storybook test for multiple funding request

* requested changes

* requested changes

---------

Co-authored-by: Theophile Sandoz <theophile.sandoz@gmail.com>
  • Loading branch information
vrrayz and thesan authored Aug 16, 2023
1 parent 3d2099e commit 83b4ef3
Show file tree
Hide file tree
Showing 12 changed files with 546 additions and 29 deletions.
15 changes: 15 additions & 0 deletions packages/ui/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import { ModalContextProvider } from '../src/common/providers/modal/provider'
import { TransactionStatusProvider } from '../src/common/providers/transactionStatus/provider'
import { MockProvidersDecorator, MockRouterDecorator } from '../src/mocks/providers'
import { i18next } from '../src/services/i18n'
import { KeyringContext } from '../src/common/providers/keyring/context'
import { Keyring } from '@polkadot/ui-keyring'


configure({ testIdAttribute: 'id' })

Expand Down Expand Up @@ -64,11 +67,23 @@ const ModalDecorator: Decorator = (Story) => (
</TransactionStatusProvider>
)

const KeyringDecorator: Decorator = (Story) => {
const keyring = {
encodeAddress: (address: string) => address,
decodeAddress: (address: string) => {
if (!/^[A-HJ-NP-Za-km-z1-9]{10,}$/.test(address)) throw new Error('Invalid address')
else return address
},
} as unknown as Keyring
return <KeyringContext.Provider value={keyring}><Story /></KeyringContext.Provider>
}

export const decorators = [
ModalDecorator,
stylesWrapperDecorator,
i18nextDecorator,
RHFDecorator,
KeyringDecorator,
MockProvidersDecorator,
MockRouterDecorator,
]
Expand Down
89 changes: 89 additions & 0 deletions packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,95 @@ export const SpecificParametersFundingRequest: Story = {
}),
}

export const SpecificParametersMultipleFundingRequest: Story = {
play: specificParametersTest('Funding Request', async ({ args, createProposal, modal, step }) => {
const bob = member('bob')
const charlie = member('charlie')
await createProposal(async () => {
const nextButton = getButtonByText(modal, 'Create proposal')
expect(nextButton).toBeDisabled()

await userEvent.click(modal.getByTestId('pay-multiple'))

const csvField = modal.getByTestId('accounts-amounts')

// Invalid
await userEvent.clear(csvField)
await userEvent.type(csvField, `${alice.controllerAccount},500${bob.controllerAccount},500`)
expect(await modal.findByText(/Not valid CSV format/))
// ensure its not being open-able while the CSV syntax is valid
const previewButton = getButtonByText(modal, 'Preview and Validate')
expect(previewButton).toBeDisabled()
await waitFor(() => expect(modal.queryByTestId('sidePanel-overlay')).toBeNull())
expect(nextButton).toBeDisabled()

// Invalid Accounts error
await userEvent.clear(csvField)
await userEvent.type(csvField, `5GNJqTPy,500\n${bob.controllerAccount},500`)

await waitFor(() => expect(modal.queryByText(/Not valid CSV format/)).toBeNull())
expect(await modal.findByText(/Please preview and validate the inputs to proceed/))
expect(nextButton).toBeDisabled()
expect(previewButton).toBeEnabled()

await userEvent.click(previewButton)
expect(await modal.findByText(/Incorrect destination accounts detected/))
await userEvent.click(modal.getByTestId('sidePanel-overlay'))

// Max Amount error
await userEvent.clear(csvField)
await userEvent.type(csvField, `${alice.controllerAccount},166667\n${bob.controllerAccount},500`)
expect(await modal.findByText(/Please preview and validate the inputs to proceed/))
expect(nextButton).toBeDisabled()
await waitFor(() => expect(previewButton).toBeEnabled())
await userEvent.click(previewButton)
expect(await modal.findByText(/Max payment amount is exceeded/))
await userEvent.click(modal.getByTestId('sidePanel-overlay')) //ensure create proposal is still disabled
expect(nextButton).toBeDisabled()

// Max Allowed Accounts error
await userEvent.clear(csvField)
await userEvent.type(
csvField,
`${alice.controllerAccount},400\n${bob.controllerAccount},500\n${charlie.controllerAccount},500`
)
expect(await modal.findByText(/Please preview and validate the inputs to proceed/))
expect(nextButton).toBeDisabled()
await waitFor(() => expect(previewButton).toBeEnabled())
await userEvent.click(previewButton)
expect(await modal.findByText(/Maximum allowed accounts exceeded/))
await userEvent.click(modal.getByTestId('sidePanel-overlay')) //ensure create proposal is still disabled
expect(nextButton).toBeDisabled()

// delete one account from the list'
await waitFor(() => expect(previewButton).toBeEnabled())
await userEvent.click(previewButton)
await userEvent.click(modal.getByTestId('removeAccount-2'))
await waitFor(() => expect(modal.queryByText(/Maximum allowed accounts exceeded/)).toBeNull())
await userEvent.click(modal.getByTestId('sidePanel-overlay'))

// Valid
await userEvent.clear(csvField)
await userEvent.type(csvField, `${alice.controllerAccount},500\n${bob.controllerAccount},500`)
expect(nextButton).toBeDisabled()

await waitFor(() => expect(previewButton).toBeEnabled())
await userEvent.click(previewButton)
await userEvent.click(modal.getByTestId('sidePanel-overlay'))
})

step('Transaction parameters', () => {
const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
expect(specificParameters.toJSON()).toEqual({
fundingRequest: [
{ account: alice.controllerAccount, amount: 500_0000000000 },
{ account: bob.controllerAccount, amount: 500_0000000000 },
],
})
})
}),
}

export const SpecificParametersSetReferralCut: Story = {
play: specificParametersTest('Set Referral Cut', async ({ args, createProposal, modal, step }) => {
await createProposal(async () => {
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/mocks/data/proposals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ export const proposalsPagesChain = (

proposalsCodex: {
fundingRequestProposalMaxTotalAmount: joy(166_666),
fundingRequestProposalMaxAccounts: 2,
setMaxValidatorCountProposalMaxValidators,

...Object.fromEntries(
Expand Down
1 change: 1 addition & 0 deletions packages/ui/src/proposals/constants/regExp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const CSV_PATTERN = /^([^,:;]+),([^,:;]+)(\n[^,:;]+,[^,:;]+)*(\n)?$/
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ export const AddNewProposalModal = () => {
const schema = useMemo(() => schemaFactory(api), [!api])

const path = useMemo(() => machineStateConverter(state.value) as keyof AddNewProposalForm, [state.value])

const form = useForm<AddNewProposalForm>({
resolver: useYupValidationResolver<AddNewProposalForm>(schema, path),
mode: 'onChange',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,41 @@
import React from 'react'
import React, { useEffect, useState } from 'react'
import { useFormContext } from 'react-hook-form'
import styled from 'styled-components'

import { SelectAccount } from '@/accounts/components/SelectAccount'
import { CurrencyName } from '@/app/constants/currency'
import { InputComponent, TokenInput } from '@/common/components/forms'
import { ButtonPrimary } from '@/common/components/buttons'
import {
InlineToggleWrap,
InputComponent,
Label,
ToggleCheckbox,
TokenInput,
InputTextarea,
} from '@/common/components/forms'
import { Arrow } from '@/common/components/icons'
import { Row } from '@/common/components/Modal'
import { RowGapBlock } from '@/common/components/page/PageContent'
import { TextMedium } from '@/common/components/typography'
import { Tooltip, TooltipDefault } from '@/common/components/Tooltip'
import { TextMedium, TextSmall, TextInlineSmall } from '@/common/components/typography'

import { PreviewAndValidateModal } from './modals/PreviewAndValidate'
import { ErrorPrompt, Prompt } from './Prompt'

export const FundingRequest = () => {
const { watch, setValue, getFieldState } = useFormContext()
const [isPreviewModalShown, setIsPreviewModalShown] = useState(false)
const [payMultiple] = watch(['fundingRequest.payMultiple'])
const [hasPreviewedInput] = watch(['fundingRequest.hasPreviewedInput'], { 'fundingRequest.hasPreviewedInput': true })
const csvInput = watch('fundingRequest.csvInput')
useEffect(() => {
if (getFieldState('fundingRequest.accountsAndAmounts')) {
setValue('fundingRequest.accountsAndAmounts', undefined, { shouldValidate: true })
setValue('fundingRequest.hasPreviewedInput', false, { shouldValidate: true })
}
}, [csvInput])
const canPreviewInput =
getFieldState('fundingRequest.csvInput').isDirty && !getFieldState('fundingRequest.csvInput').error
return (
<RowGapBlock gap={24}>
<Row>
Expand All @@ -17,22 +45,91 @@ export const FundingRequest = () => {
</RowGapBlock>
</Row>
<Row>
<RowGapBlock gap={20}>
<InputComponent
label="Amount"
tight
units={CurrencyName.integerValue}
required
message="Amount must be greater than zero"
name="fundingRequest.amount"
>
<TokenInput id="amount-input" placeholder="0" name="fundingRequest.amount" />
</InputComponent>
<InputComponent label="Recipient account" required inputSize="l">
<SelectAccount name="fundingRequest.account" />
</InputComponent>
<RowGapBlock gap={payMultiple ? 6 : 24}>
<Row>
<InlineToggleWrap>
<Label>Pay multiple</Label>
<Tooltip
tooltipTitle="Pay multiple"
tooltipText="For multiple accounts and amounts, follow this CSV pattern:<br/>
account1, amount1<br/>
account2, amount2<br/>
...<br/>
account20, amount20"
>
<TooltipDefault />
</Tooltip>
</InlineToggleWrap>
<ToggleCheckbox falseLabel="No" trueLabel="Yes" name="fundingRequest.payMultiple" id="pay-multiple" />
</Row>
{payMultiple && (
<Row>
<Prompt>
<TextSmall>
For <TextInlineSmall bold>multiple accounts and amounts</TextInlineSmall>, follow this CSV pattern:
<br />
account1, amount1
<br />
account2, amount2
<br />
...
<br />
account20, amount20
</TextSmall>
</Prompt>
</Row>
)}
</RowGapBlock>
</Row>
<Row>
{payMultiple ? (
<RowGapBlock gap={12}>
<InputComponent
label="Destination accounts and transfer amounts"
required
message={'You can select up to 20 recipients'}
name="fundingRequest.csvInput"
id="accounts-amounts"
inputSize="xl"
>
<InputTextarea
id="accounts-amounts"
name="fundingRequest.csvInput"
placeholder="Destination account address and amount"
/>
</InputComponent>
<HiddenCheckBox name="fundingRequest.hasPreviewedInput" checked={hasPreviewedInput} />
{canPreviewInput && !hasPreviewedInput && (
<ErrorPrompt>Please preview and validate the inputs to proceed</ErrorPrompt>
)}
<ButtonPrimary size="medium" disabled={!canPreviewInput} onClick={() => setIsPreviewModalShown(true)}>
Preview and Validate <Arrow direction="right" />
</ButtonPrimary>
</RowGapBlock>
) : (
<RowGapBlock gap={20}>
<InputComponent
label="Amount"
tight
units={CurrencyName.integerValue}
required
message="Amount must be greater than zero"
name="fundingRequest.amount"
>
<TokenInput id="amount-input" placeholder="0" name="fundingRequest.amount" />
</InputComponent>
<InputComponent label="Recipient account" required inputSize="l">
<SelectAccount name="fundingRequest.account" />
</InputComponent>
</RowGapBlock>
)}
</Row>
{isPreviewModalShown && <PreviewAndValidateModal onClose={setIsPreviewModalShown} />}
</RowGapBlock>
)
}
const HiddenCheckBox = styled.input.attrs({ type: 'checkbox' })`
margin-top: -12px;
height: 0px;
visibility: hidden;
`
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React from 'react'
import styled from 'styled-components'

import { QuestionIcon } from '@/common/components/icons'
import { BorderRad, Colors } from '@/common/constants'

export interface PromptProps {
children: React.ReactNode
}

export const Prompt = ({ children }: PromptProps) => {
return (
<PromptContainer>
<IconSection>
<PromptQuestion>
<QuestionIcon />
</PromptQuestion>
</IconSection>
{children}
</PromptContainer>
)
}
const PromptContainer = styled.div`
display: grid;
grid-template-columns: 48px 1fr;
border-left: 4px solid ${Colors.Blue[200]};
color: ${Colors.Black[500]};
`
const IconSection = styled.div`
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
`
const PromptQuestion = styled.div`
display: flex;
position: relative;
justify-content: center;
align-items: center;
width: 16px;
height: 16px;
border-radius: ${BorderRad.full};
border: 1px solid ${Colors.Black[900]};
`
export const ErrorPrompt = styled.div`
display: flex;
justify-content: center;
flex-direction: column;
border-left: 4px solid ${Colors.Red[200]};
padding-left: 10.78px;
background-color: ${Colors.Red[50]};
color: ${Colors.Red[400]};
width: 504px;
height: 56px;
border-radius: 2px;
font-weight: 400;
`
Loading

2 comments on commit 83b4ef3

@vercel
Copy link

@vercel vercel bot commented on 83b4ef3 Aug 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 83b4ef3 Aug 18, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

pioneer-2 – ./

pioneer-2-git-dev-joystream.vercel.app
pioneer-2-joystream.vercel.app
pioneer-2.vercel.app

Please sign in to comment.