diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx
index fc6b5ec906..afe1f720e6 100644
--- a/packages/ui/.storybook/preview.tsx
+++ b/packages/ui/.storybook/preview.tsx
@@ -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' })
@@ -64,11 +67,23 @@ const ModalDecorator: Decorator = (Story) => (
)
+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
+}
+
export const decorators = [
ModalDecorator,
stylesWrapperDecorator,
i18nextDecorator,
RHFDecorator,
+ KeyringDecorator,
MockProvidersDecorator,
MockRouterDecorator,
]
diff --git a/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx
index 20eac6baec..1852e1232d 100644
--- a/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx
+++ b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx
@@ -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 () => {
diff --git a/packages/ui/src/mocks/data/proposals.ts b/packages/ui/src/mocks/data/proposals.ts
index cee1a07020..9c5bdd8f3e 100644
--- a/packages/ui/src/mocks/data/proposals.ts
+++ b/packages/ui/src/mocks/data/proposals.ts
@@ -203,6 +203,7 @@ export const proposalsPagesChain = (
proposalsCodex: {
fundingRequestProposalMaxTotalAmount: joy(166_666),
+ fundingRequestProposalMaxAccounts: 2,
setMaxValidatorCountProposalMaxValidators,
...Object.fromEntries(
diff --git a/packages/ui/src/proposals/constants/regExp.ts b/packages/ui/src/proposals/constants/regExp.ts
new file mode 100644
index 0000000000..2223a831e0
--- /dev/null
+++ b/packages/ui/src/proposals/constants/regExp.ts
@@ -0,0 +1 @@
+export const CSV_PATTERN = /^([^,:;]+),([^,:;]+)(\n[^,:;]+,[^,:;]+)*(\n)?$/
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx b/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx
index 113c6a43f6..c1fe25bee3 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx
@@ -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({
resolver: useYupValidationResolver(schema, path),
mode: 'onChange',
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/FundingRequest.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/FundingRequest.tsx
index c0c86840ec..602940d9ed 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/FundingRequest.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/FundingRequest.tsx
@@ -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 (
@@ -17,22 +45,91 @@ export const FundingRequest = () => {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ {payMultiple && (
+
+
+
+ For multiple accounts and amounts, follow this CSV pattern:
+
+ account1, amount1
+
+ account2, amount2
+
+ ...
+
+ account20, amount20
+
+
+
+ )}
+
+ {payMultiple ? (
+
+
+
+
+
+ {canPreviewInput && !hasPreviewedInput && (
+ Please preview and validate the inputs to proceed
+ )}
+ setIsPreviewModalShown(true)}>
+ Preview and Validate
+
+
+ ) : (
+
+
+
+
+
+
+
+
+ )}
+
+ {isPreviewModalShown && }
)
}
+const HiddenCheckBox = styled.input.attrs({ type: 'checkbox' })`
+ margin-top: -12px;
+ height: 0px;
+ visibility: hidden;
+`
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/Prompt.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/Prompt.tsx
new file mode 100644
index 0000000000..6c2c79869a
--- /dev/null
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/Prompt.tsx
@@ -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 (
+
+
+
+
+
+
+ {children}
+
+ )
+}
+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;
+`
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/modals/PreviewAndValidate/PreviewAndValidateModal.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/modals/PreviewAndValidate/PreviewAndValidateModal.tsx
new file mode 100644
index 0000000000..db13623f14
--- /dev/null
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/modals/PreviewAndValidate/PreviewAndValidateModal.tsx
@@ -0,0 +1,198 @@
+import { isBn } from '@polkadot/util'
+import BN from 'bn.js'
+import React, { useCallback, useEffect, useState } from 'react'
+import { useFormContext } from 'react-hook-form'
+import styled from 'styled-components'
+
+import { AccountInfo } from '@/accounts/components/AccountInfo'
+import { useMyAccounts } from '@/accounts/hooks/useMyAccounts'
+import { accountOrNamed } from '@/accounts/model/accountOrNamed'
+import { isValidAddress } from '@/accounts/model/isValidAddress'
+import { Account, AccountOption } from '@/accounts/types'
+import { useApi } from '@/api/hooks/useApi'
+import { Close, CloseButton } from '@/common/components/buttons'
+import {
+ AccountRow,
+ BalanceInfoInRow,
+ InfoTitle,
+ InfoValue,
+ ModalFooter,
+ Row,
+ TransactionInfoContainer,
+} from '@/common/components/Modal'
+import { RowGapBlock } from '@/common/components/page/PageContent'
+import {
+ SidePane,
+ SidePaneBody,
+ SidePaneGlass,
+ SidePaneHeader,
+ SidePanelTop,
+ SidePaneTitle,
+} from '@/common/components/SidePane'
+import { TransactionFee } from '@/common/components/TransactionFee'
+import { TokenValue } from '@/common/components/typography'
+import { Colors } from '@/common/constants'
+import { useKeyring } from '@/common/hooks/useKeyring'
+import { formatJoyValue } from '@/common/model/formatters'
+import { joy } from '@/mocks/helpers'
+
+import { ErrorPrompt } from '../../Prompt'
+
+interface PreviewAndValidateModalProps {
+ onClose: (bool: boolean) => void
+}
+interface AccountAndAmount {
+ account: Account
+ amount: BN
+ isValidAccount: boolean
+}
+
+export const PreviewAndValidateModal = ({ onClose }: PreviewAndValidateModalProps) => {
+ const { api } = useApi()
+ const { setValue, getValues } = useFormContext()
+ const maxTotalAmount = api?.consts.proposalsCodex.fundingRequestProposalMaxTotalAmount
+ const maxAllowedAccounts = api?.consts.proposalsCodex.fundingRequestProposalMaxAccounts?.toNumber()
+ const keyring = useKeyring()
+ const { allAccounts } = useMyAccounts()
+ const accounts = allAccounts as AccountOption[]
+ const [previewAccounts, setPreviewAccounts] = useState([])
+ const [totalAmount, setTotalAmount] = useState(new BN(0))
+ const [errorMessages, setErrorMessages] = useState([])
+
+ const onBackgroundClick = (e: React.MouseEvent) => {
+ if (e.target === e.currentTarget) {
+ closeModalWithData()
+ }
+ }
+
+ const removeAccount = useCallback((index: number) => {
+ setErrorMessages([])
+ setPreviewAccounts((prev) => prev.filter((item, i) => index !== i))
+ }, [])
+
+ const closeModalWithData = useCallback(() => {
+ const accountsAndAmounts: { amount: BN; account: string }[] = []
+ previewAccounts.map((item) => {
+ accountsAndAmounts.push({ amount: item.amount, account: item.account.address })
+ })
+ setValue('fundingRequest.hasPreviewedInput', true, { shouldValidate: true })
+ if (errorMessages.length === 0) {
+ setValue('fundingRequest.accountsAndAmounts', accountsAndAmounts, { shouldValidate: true })
+ } else {
+ setValue('fundingRequest.accountsAndAmounts', undefined, { shouldValidate: true })
+ }
+ onClose(false)
+ }, [previewAccounts, errorMessages])
+ useEffect(() => {
+ if (previewAccounts.length > 0) {
+ const value = previewAccounts
+ .map(({ account, amount }) => `${account.address},${formatJoyValue(amount).replaceAll(',', '')}`)
+ .join('\n')
+ setValue('fundingRequest.csvInput', value)
+ }
+ }, [previewAccounts])
+
+ useEffect(() => {
+ const csvInput = getValues('fundingRequest.csvInput').split('\n')
+ setPreviewAccounts(
+ csvInput.map((item: string) => {
+ const splitAccountsAndAmounts = item.split(',')
+ const amount = new BN(joy(splitAccountsAndAmounts[1]))
+ const isValidAccount = isValidAddress(splitAccountsAndAmounts[0], keyring)
+ return {
+ account: accountOrNamed(accounts, splitAccountsAndAmounts[0], 'Unknown Member'),
+ amount: amount,
+ isValidAccount,
+ }
+ })
+ )
+ }, [])
+ useEffect(() => {
+ let total = new BN(0)
+ let totalInvalidAccounts = 0
+ previewAccounts?.map((item) => {
+ total = total.add(item.amount)
+ totalInvalidAccounts += !item.isValidAccount ? 1 : 0
+ })
+ const messages: string[] = []
+ if (totalInvalidAccounts > 0) {
+ messages.push('Incorrect destination accounts detected')
+ }
+ if (maxAllowedAccounts && previewAccounts?.length > maxAllowedAccounts) {
+ messages.push('Maximum allowed accounts exceeded')
+ }
+ if (messages.length > 0) {
+ setErrorMessages((prev) => [...prev, ...messages])
+ }
+ setTotalAmount(total)
+ }, [previewAccounts])
+ useEffect(() => {
+ if (totalAmount.gt(isBn(maxTotalAmount) ? maxTotalAmount : new BN(0))) {
+ setErrorMessages((prev) => [...prev, 'Max payment amount is exceeded'])
+ }
+ }, [totalAmount])
+ return (
+
+
+
+
+ Preview And Validate
+ closeModalWithData()}>
+
+
+
+
+
+
+ {errorMessages?.map((message) => (
+ {message}
+ ))}
+
+
+
+ {previewAccounts?.map((previewAccount, i) => (
+
+
+
+ Amount
+
+
+
+ removeAccount(i)} />
+
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+const CustomAccountRow = styled(AccountRow)`
+ margin-bottom: 4px;
+ padding-right: 16px;
+ &.error {
+ border-color: ${Colors.Red[400]};
+ }
+`
+const CustomBalanceInfoInRow = styled(BalanceInfoInRow)`
+ grid-template-columns: 1fr 168px 72px;
+ ${Close} {
+ margin-left: auto;
+ }
+`
+const PreviewPanel = styled(SidePane)`
+ grid-template-rows: auto 1fr auto;
+`
+const PreviewPanelHeader = styled(SidePaneHeader)`
+ padding: 12px 24px;
+`
+const PreviewPanelBody = styled(SidePaneBody)`
+ padding: 12px 24px;
+`
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/modals/PreviewAndValidate/index.ts b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/modals/PreviewAndValidate/index.ts
new file mode 100644
index 0000000000..83739a6af7
--- /dev/null
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/modals/PreviewAndValidate/index.ts
@@ -0,0 +1 @@
+export * from './PreviewAndValidateModal'
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/getSpecificParameters.ts b/packages/ui/src/proposals/modals/AddNewProposal/getSpecificParameters.ts
index 7220595444..1331d34af4 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/getSpecificParameters.ts
+++ b/packages/ui/src/proposals/modals/AddNewProposal/getSpecificParameters.ts
@@ -30,9 +30,9 @@ export const getSpecificParameters = async (
}
case 'fundingRequest': {
return createType('PalletProposalsCodexProposalDetails', {
- FundingRequest: [
- { amount: specifics?.fundingRequest?.amount, account: specifics?.fundingRequest?.account?.address },
- ],
+ FundingRequest: specifics?.fundingRequest?.payMultiple
+ ? specifics?.fundingRequest?.accountsAndAmounts
+ : [{ amount: specifics?.fundingRequest?.amount, account: specifics?.fundingRequest?.account?.address }],
})
}
case 'runtimeUpgrade': {
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/helpers.ts b/packages/ui/src/proposals/modals/AddNewProposal/helpers.ts
index 4b9e16d647..d5f07c38f8 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/helpers.ts
+++ b/packages/ui/src/proposals/modals/AddNewProposal/helpers.ts
@@ -17,6 +17,7 @@ import {
} from '@/common/utils/validation'
import { AccountSchema, StakingAccountSchema } from '@/memberships/model/validation'
import { Member } from '@/memberships/types'
+import { isValidCSV } from '@/proposals/model/validation'
import { ProposalType } from '@/proposals/types'
import { GroupIdName } from '@/working-groups/types'
@@ -36,6 +37,10 @@ export const defaultProposalValues = {
discussionWhitelist: [],
isDiscussionClosed: false,
},
+ fundingRequest: {
+ payMultiple: false,
+ hasPreviewedInput: true,
+ },
updateWorkingGroupBudget: {
isPositive: true,
},
@@ -69,8 +74,12 @@ export interface AddNewProposalForm {
signal?: string
}
fundingRequest: {
- amount: BN
- account: Account
+ amount?: BN
+ account?: Account
+ payMultiple?: boolean
+ csvInput?: string
+ accountsAndAmounts?: { amount: BN; account: string }[]
+ hasPreviewedInput?: boolean
}
runtimeUpgrade: {
runtime?: File
@@ -204,13 +213,39 @@ export const schemaFactory = (api?: Api) => {
signal: Yup.string().required('Field is required').trim(),
}),
fundingRequest: Yup.object().shape({
- amount: BNSchema.test(moreThanMixed(0, ''))
- // todo: change funding request to allow upload request in file
- .test(
- maxMixed(api?.consts.proposalsCodex.fundingRequestProposalMaxTotalAmount, 'Maximal amount allowed is ${max}')
- )
- .required('Field is required'),
- account: AccountSchema.required('Field is required'),
+ payMultiple: Yup.boolean().required(),
+ amount: BNSchema.when('payMultiple', {
+ is: false,
+ then: (schema) =>
+ schema
+ .test(moreThanMixed(0, ''))
+ .test(
+ maxMixed(
+ api?.consts.proposalsCodex.fundingRequestProposalMaxTotalAmount,
+ 'Maximal amount allowed is ${max}'
+ )
+ )
+ .required('Field is required'),
+ }),
+ account: AccountSchema.when('payMultiple', {
+ is: false,
+ then: (schema) => schema.required('Field is required'),
+ }),
+ hasPreviewedInput: Yup.boolean().when('payMultiple', {
+ is: true,
+ then: (schema) =>
+ schema
+ .test('previewedinput', 'Please preview', (value) => typeof value !== 'undefined' && value)
+ .required('Field is required'),
+ }),
+ csvInput: Yup.string().when('payMultiple', {
+ is: true,
+ then: (schema) => schema.test(isValidCSV('Not valid CSV format')).required('Field is required'),
+ }),
+ accountsAndAmounts: Yup.array().when('payMultiple', {
+ is: true,
+ then: (schema) => schema.required(),
+ }),
}),
runtimeUpgrade: Yup.object().shape({
runtime: Yup.mixed()
diff --git a/packages/ui/src/proposals/model/validation.ts b/packages/ui/src/proposals/model/validation.ts
new file mode 100644
index 0000000000..bd8b63a66d
--- /dev/null
+++ b/packages/ui/src/proposals/model/validation.ts
@@ -0,0 +1,22 @@
+import * as Yup from 'yup'
+import { AnyObject } from 'yup/lib/types'
+
+import { CSV_PATTERN } from '../constants/regExp'
+
+export const isValidCSV = (message: string): Yup.TestConfig => ({
+ message,
+ name: 'isValidCSV',
+ exclusive: false,
+ test(value: string) {
+ if (!CSV_PATTERN.test(value)) return false
+
+ const pairs = value.split('\n')
+
+ for (const pair of pairs) {
+ const [, amount] = pair.split(',')
+ if (!Number(amount)) return false
+ }
+
+ return true
+ },
+})