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 + }, +})