diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx
index 26bb759588..fc6b5ec906 100644
--- a/packages/ui/.storybook/preview.tsx
+++ b/packages/ui/.storybook/preview.tsx
@@ -1,16 +1,23 @@
import { Decorator } from '@storybook/react'
+import { configure } from '@storybook/testing-library'
import React from 'react'
import { I18nextProvider } from 'react-i18next'
import { useForm, FormProvider } from 'react-hook-form'
-import { MemoryRouter, Redirect, Route, Switch } from 'react-router'
import { createGlobalStyle } from 'styled-components'
-import { NotFound } from '../src/app/pages/NotFound'
+import { GlobalModals } from '../src/app/GlobalModals'
import { GlobalStyle } from '../src/app/providers/GlobalStyle'
+import { OnBoardingProvider } from '../src/common/providers/onboarding/provider'
+import { NotificationsHolder } from '../src/common/components/page/SideNotification'
+import { TransactionStatus } from '../src/common/components/TransactionStatus/TransactionStatus'
import { Colors } from '../src/common/constants'
-import { MockProvidersDecorator } from '../src/mocks/providers'
+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'
+configure({ testIdAttribute: 'id' })
+
const stylesWrapperDecorator: Decorator = (Story) => (
<>
@@ -43,22 +50,27 @@ const RHFDecorator: Decorator = (Story) => {
)
}
-const RouterDecorator: Decorator = (Story, { parameters }) => (
-
-
-
-
-
-
-
+const ModalDecorator: Decorator = (Story) => (
+
+
+
+
+
+
+
+
+
+
+
)
export const decorators = [
+ ModalDecorator,
stylesWrapperDecorator,
i18nextDecorator,
RHFDecorator,
MockProvidersDecorator,
- RouterDecorator,
+ MockRouterDecorator,
]
export const parameters = {
@@ -91,7 +103,7 @@ export const parameters = {
options: {
storySort: {
method: 'alphabetical',
- order: ['Common'],
+ order: ['App', 'Pages', 'Common'],
},
},
}
diff --git a/packages/ui/src/accounts/providers/balances/provider.tsx b/packages/ui/src/accounts/providers/balances/provider.tsx
index 763e6dbf14..54c2af7551 100644
--- a/packages/ui/src/accounts/providers/balances/provider.tsx
+++ b/packages/ui/src/accounts/providers/balances/provider.tsx
@@ -25,6 +25,8 @@ export const BalancesContextProvider = (props: Props) => {
)
const balances = useMemo(() => {
+ if (!addresses.length) return {}
+
if (!isLoading && result)
return result.reduce((acc, balance, index) => {
return {
@@ -32,7 +34,7 @@ export const BalancesContextProvider = (props: Props) => {
...acc,
}
}, {} as AddressToBalanceMap)
- }, [result])
+ }, [result, addresses])
return {props.children}
}
diff --git a/packages/ui/src/app/App.stories.tsx b/packages/ui/src/app/App.stories.tsx
new file mode 100644
index 0000000000..23008abecd
--- /dev/null
+++ b/packages/ui/src/app/App.stories.tsx
@@ -0,0 +1,410 @@
+import { metadataToBytes } from '@joystream/js/utils'
+import { MembershipMetadata } from '@joystream/metadata-protobuf'
+import { expect } from '@storybook/jest'
+import { Meta, StoryContext, StoryObj } from '@storybook/react'
+import { userEvent, waitFor, within } from '@storybook/testing-library'
+import React, { FC } from 'react'
+import { createGlobalStyle } from 'styled-components'
+
+import { Page, Screen } from '@/common/components/page/Page'
+import { Colors } from '@/common/constants'
+import { GetMemberDocument } from '@/memberships/queries'
+import { Membership, member } from '@/mocks/data/members'
+import { Container, getButtonByText, joy, selectFromDropdown, withinModal } from '@/mocks/helpers'
+import { MocksParameters } from '@/mocks/providers'
+
+import { App } from './App'
+import { OnBoardingOverlay } from './components/OnboardingOverlay/OnBoardingOverlay'
+import { SideBar } from './components/SideBar'
+
+type Args = {
+ isLoggedIn: boolean
+ hasMemberships: boolean
+ hasFunds: boolean
+ hasAccounts: boolean
+ hasWallet: boolean
+ isRPCNodeConnected: boolean
+ onBuyMembership: CallableFunction
+ onTransfer: CallableFunction
+}
+
+type Story = StoryObj>
+
+const alice = member('alice')
+const bob = member('bob')
+const charlie = member('charlie')
+
+const MEMBER_DATA = {
+ id: '12',
+ handle: 'realbobbybob',
+ metadata: {
+ name: 'BobbyBob',
+ about: 'Lorem ipsum...',
+ avatar: { avatarUri: 'https://api.dicebear.com/6.x/bottts-neutral/svg?seed=bob' },
+ },
+}
+
+const NoPaddingStyle = createGlobalStyle`
+ html, body {
+ padding: 0 !important;
+ }
+`
+
+export default {
+ title: 'App',
+ component: App,
+
+ argTypes: {
+ onBuyMembership: { action: 'BuyMembership' },
+ onTransfer: { action: 'BalanceTransfer' },
+ },
+
+ args: {
+ isLoggedIn: true,
+ hasMemberships: true,
+ hasAccounts: true,
+ hasFunds: true,
+ hasWallet: true,
+ isRPCNodeConnected: true,
+ },
+
+ parameters: {
+ totalBalance: 100,
+
+ mocks: ({ args, parameters }: StoryContext): MocksParameters => {
+ const account = (member: Membership) => ({
+ balances: args.hasFunds ? parameters.totalBalance : 0,
+ ...(args.hasMemberships ? { member } : { account: { name: member.handle, address: member.controllerAccount } }),
+ })
+
+ return {
+ accounts: {
+ active: args.isLoggedIn ? 'alice' : undefined,
+ list: args.hasMemberships || args.hasAccounts ? [account(alice), account(bob), account(charlie)] : [],
+ hasWallet: args.hasWallet,
+ },
+
+ chain: !args.isRPCNodeConnected
+ ? undefined
+ : {
+ query: {
+ members: { membershipPrice: joy(20) },
+ council: { stage: { stage: { isIdle: true }, changedAt: 123 } },
+ referendum: { stage: {} },
+ },
+
+ tx: {
+ balances: {
+ transfer: {
+ event: 'Transfer',
+ onSend: args.onTransfer,
+ },
+ },
+ members: {
+ buyMembership: {
+ event: 'MembershipBought',
+ data: [MEMBER_DATA.id],
+ onSend: args.onBuyMembership,
+ failure: parameters.txFailure,
+ },
+ },
+ },
+ },
+
+ queryNode: [
+ {
+ query: GetMemberDocument,
+ data: { membershipByUniqueInput: { ...bob, ...MEMBER_DATA, invitees: [] } },
+ },
+ ],
+ }
+ },
+ },
+
+ render: (
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ args // This parameter is needs for the controls to appear in stories
+ ) => (
+
+
+
+
+
+
+
+ ),
+} satisfies Meta
+
+export const Default: Story = {}
+
+export const UnreachableRPCNode: Story = { args: { isRPCNodeConnected: false } }
+
+// ----------------------------------------------------------------------------
+// Test Switch membership modal
+// ----------------------------------------------------------------------------
+
+export const SwitchMembership: Story = {
+ play: async ({ canvasElement, step }) => {
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+
+ expect(screen.queryByText('Become a member')).toBeNull()
+
+ await step('Switch active membership to bob', async () => {
+ expect(screen.queryByText('bob')).toBeNull()
+ await userEvent.click(screen.getByText('alice'))
+
+ await userEvent.click(modal.getByText('bob'))
+ expect(screen.queryByText('alice')).toBeNull()
+ expect(screen.getByText('bob'))
+ })
+
+ await step('Sign out', async () => {
+ await userEvent.click(screen.getByText('bob'))
+ await userEvent.click(modal.getByText('Sign Out'))
+ expect(modal.getByText('Sign out of bob ?'))
+ await userEvent.click(getButtonByText(modal, 'Sign Out'))
+
+ expect(getButtonByText(screen, 'Select membership'))
+ })
+ },
+}
+
+// ----------------------------------------------------------------------------
+// Test On Boarding Overlay
+// ----------------------------------------------------------------------------
+
+export const OnBoardingOverlayStory: Story = {
+ args: { hasWallet: false, hasAccounts: false, hasFunds: false, hasMemberships: false, isLoggedIn: false },
+
+ name: 'On Boarding Overlay',
+
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+
+ expect(screen.getByText('Become a member'))
+ userEvent.click(screen.getByText('Show how'))
+ expect(screen.getByText('What are the benefits?'))
+ expect(screen.getByText('How to become a member?'))
+
+ expect(screen.getByText('Connect wallet', { selector: 'h6' }))
+ expect(screen.getByText('Connect account', { selector: 'h6' }))
+ expect(screen.getByText('Create free membership', { selector: 'h6' }))
+ },
+}
+
+// ----------------------------------------------------------------------------
+// Test On Boarding flow
+// ----------------------------------------------------------------------------
+
+const expectActiveStepToBe = (modal: Container, text: string) =>
+ expect(modal.getByText(text, { selector: 'h6' }).parentElement?.previousElementSibling).toHaveStyle(
+ `background-color: ${Colors.Blue[500]}`
+ )
+
+export const ConnectWallet: Story = {
+ args: { hasWallet: false, hasAccounts: false, hasFunds: false, hasMemberships: false, isLoggedIn: false },
+
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+
+ expect(screen.getByText('Become a member'))
+
+ await userEvent.click(getButtonByText(screen, 'Connect Wallet', { selector: 'nav *' }))
+
+ const modal = withinModal(canvasElement)
+ expectActiveStepToBe(modal, 'Connect wallet')
+ expect(modal.getByText('Select Wallet'))
+ const pluginButton = getButtonByText(modal, 'Install extension')
+ expect(pluginButton).toBeDisabled()
+ await userEvent.click(modal.getByText('Polkadot.js'))
+ expect(pluginButton).toBeEnabled()
+ },
+}
+
+export const NoAccount: Story = {
+ args: { hasAccounts: false, hasFunds: false, hasMemberships: false, isLoggedIn: false },
+
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+
+ expect(screen.getByText('Become a member'))
+
+ await userEvent.click(getButtonByText(screen, 'Join Now', { selector: 'nav *' }))
+
+ const modal = withinModal(canvasElement)
+ expectActiveStepToBe(modal, 'Connect account')
+ expect(modal.getByText('Connect account', { selector: '[class^=ModalBody] *' }))
+ expect(getButtonByText(modal, 'Return to wallet selection')).toBeEnabled()
+ expect(getButtonByText(modal, 'Connect Account')).toBeDisabled()
+ expect(modal.queryByText('alice')).toBeNull()
+ },
+}
+
+export const FaucetMembership: Story = {
+ args: { hasFunds: false, hasMemberships: false, isLoggedIn: false },
+
+ play: async ({ canvasElement, step }) => {
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+
+ expect(screen.getByText('Become a member'))
+
+ await userEvent.click(getButtonByText(screen, 'Join Now', { selector: 'nav *' }))
+
+ await step('Connect account', async () => {
+ expectActiveStepToBe(modal, 'Connect account')
+ expect(modal.getByText('Connect account', { selector: '[class^=ModalBody] *' }))
+
+ expect(getButtonByText(modal, 'Return to wallet selection')).toBeEnabled()
+
+ const connectAccountButton = getButtonByText(modal, 'Connect Account')
+ expect(connectAccountButton).toBeDisabled()
+ await userEvent.click(modal.getByText('alice'))
+ expect(connectAccountButton).toBeEnabled()
+
+ expect(localStorage.getItem('onboarding-membership-account')).toBeNull()
+ await userEvent.click(connectAccountButton)
+ expect(localStorage.getItem('onboarding-membership-account')).toBe(JSON.stringify(alice.controllerAccount))
+ })
+
+ await step('Create free membership', async () => {
+ await waitFor(() => expectActiveStepToBe(modal, 'Create free membership'))
+ 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.click(modal.getByLabelText(/^I agree to the/))
+ expect(getButtonByText(modal, 'Create a Membership')).toBeDisabled()
+ })
+ },
+}
+
+// ----------------------------------------------------------------------------
+// Test Buy Membership Modal
+// ----------------------------------------------------------------------------
+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.click(modal.getByLabelText(/^I agree to the/))
+}
+
+export const BuyMembershipHappy: Story = {
+ args: { hasMemberships: false, isLoggedIn: false },
+
+ play: async ({ args, canvasElement, step }) => {
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+
+ expect(screen.queryByText('Become a member')).toBeNull()
+
+ await userEvent.click(getButtonByText(screen, 'Join Now'))
+
+ await step('Form', async () => {
+ const createButton = getButtonByText(modal, 'Create a Membership')
+
+ await step('Fill', async () => {
+ expect(createButton).toBeDisabled()
+ await fillMembershipForm(modal)
+ await waitFor(() => expect(createButton).toBeEnabled())
+ })
+
+ await step('Disables button on incorrect email address', async () => {
+ await userEvent.click(modal.getByText('Email'))
+ const emailInput = modal.getByPlaceholderText('Enter Email')
+
+ await userEvent.type(emailInput, 'bobby@bob')
+ await waitFor(() => expect(createButton).toBeDisabled())
+ await userEvent.type(emailInput, '.com')
+ await waitFor(() => expect(createButton).toBeEnabled())
+ })
+
+ await userEvent.click(createButton)
+ })
+
+ await step('Sign', async () => {
+ expect(modal.getByText('Authorize transaction'))
+ expect(modal.getByText('You intend to create a new membership.'))
+ expect(modal.getByText('Creation fee:')?.nextSibling?.textContent).toBe('20')
+ expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5')
+ expect(modal.getByRole('heading', { name: 'bob' }))
+
+ await userEvent.click(getButtonByText(modal, 'Sign and create a member'))
+ })
+
+ await step('Confirm', async () => {
+ expect(await modal.findByText('Success'))
+ expect(modal.getByText(MEMBER_DATA.handle))
+
+ expect(args.onBuyMembership).toHaveBeenCalledWith({
+ rootAccount: alice.controllerAccount,
+ controllerAccount: bob.controllerAccount,
+ handle: MEMBER_DATA.handle,
+ metadata: metadataToBytes(MembershipMetadata, {
+ name: MEMBER_DATA.metadata.name,
+ about: MEMBER_DATA.metadata.about,
+ avatarUri: 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)
+
+ expect(modal.getByText('Profile'))
+ expect(modal.getByText(MEMBER_DATA.handle))
+ })
+ },
+}
+
+export const BuyMembershipNotEnoughFund: Story = {
+ args: { hasMemberships: false, isLoggedIn: false },
+ parameters: { totalBalance: 20 },
+
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+
+ await userEvent.click(getButtonByText(screen, 'Join Now'))
+
+ await fillMembershipForm(modal)
+ const createButton = getButtonByText(modal, 'Create a Membership')
+ await waitFor(() => expect(createButton).toBeEnabled())
+ await userEvent.click(createButton)
+
+ expect(modal.getByText('Insufficient funds to cover the membership creation.'))
+ expect(getButtonByText(modal, 'Sign and create a member')).toBeDisabled()
+ },
+}
+
+export const BuyMembershipTxFailure: Story = {
+ args: { hasMemberships: false, isLoggedIn: false },
+ parameters: { txFailure: 'Some error message' },
+
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+
+ await userEvent.click(getButtonByText(screen, 'Join Now'))
+
+ await fillMembershipForm(modal)
+ const createButton = getButtonByText(modal, 'Create a Membership')
+ await waitFor(() => expect(createButton).toBeEnabled())
+ await userEvent.click(createButton)
+
+ await userEvent.click(getButtonByText(modal, 'Sign and create a member'))
+
+ expect(await screen.findByText('Failure'))
+ expect(await modal.findByText('Some error message'))
+ },
+}
diff --git a/packages/ui/src/app/GlobalModals.tsx b/packages/ui/src/app/GlobalModals.tsx
index e504f407ae..7029d7e97d 100644
--- a/packages/ui/src/app/GlobalModals.tsx
+++ b/packages/ui/src/app/GlobalModals.tsx
@@ -1,5 +1,5 @@
import { get } from 'lodash'
-import React, { memo, ReactElement, useMemo } from 'react'
+import React, { memo, ReactElement, useEffect, useMemo, useState } from 'react'
import ReactDOM from 'react-dom'
import styled from 'styled-components'
@@ -205,6 +205,12 @@ export const GlobalModals = () => {
const { status } = useTransactionStatus()
const Modal = useMemo(() => (modal && modal in modals ? memo(() => modals[modal as ModalNames]) : null), [modal])
+ const [container, setContainer] = useState(document.body)
+ useEffect(() => {
+ const container = document.getElementById('modal-container')
+ if (container) setContainer(container)
+ }, [])
+
const potentialFallback = useGlobalModalHandler(currentModalMachine, hideModal)
if (modal && !GUEST_ACCESSIBLE_MODALS.includes(modal as ModalNames) && !activeMember) {
@@ -226,7 +232,7 @@ export const GlobalModals = () => {
{isClosing && }
{status === 'loadingFees' && }
,
- document.body
+ container
)
}
diff --git a/packages/ui/src/app/components/OnboardingOverlay/OnboardingOverlay.stories.tsx b/packages/ui/src/app/components/OnboardingOverlay/OnboardingOverlay.stories.tsx
deleted file mode 100644
index af850f3d55..0000000000
--- a/packages/ui/src/app/components/OnboardingOverlay/OnboardingOverlay.stories.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import { Meta, Story } from '@storybook/react'
-import React, { useEffect, useState } from 'react'
-
-import { AccountsContext } from '@/accounts/providers/accounts/context'
-import { UseAccounts } from '@/accounts/providers/accounts/provider'
-import { ApiContext } from '@/api/providers/context'
-import { OnBoardingOverlay } from '@/app/components/OnboardingOverlay/OnBoardingOverlay'
-import { TemplateBlock } from '@/common/components/storybookParts/previewStyles'
-import { OnBoardingProvider } from '@/common/providers/onboarding/provider'
-import { MembershipContext } from '@/memberships/providers/membership/context'
-import { MyMemberships } from '@/memberships/providers/membership/provider'
-import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvider'
-
-export default {
- title: 'App/OnboardingOverlay',
- component: OnBoardingOverlay,
-} as Meta
-
-const useApi = {
- isConnected: true,
- api: undefined,
- connectionState: 'connecting',
- qnConnectionState: 'connecting',
- setQnConnectionState: () => undefined,
-}
-
-const useMyAccounts: UseAccounts = {
- isLoading: false,
- hasAccounts: false,
- allAccounts: [],
- error: undefined,
-}
-const useMyMemberships: MyMemberships = {
- active: undefined,
- members: [],
- setActive: (member) => (useMyMemberships.active = member),
- isLoading: false,
- hasMembers: false,
- helpers: {
- getMemberIdByBoundAccountAddress: () => undefined,
- },
-}
-
-interface Props {
- extension: boolean
- account: boolean
- membership: boolean
-}
-
-const Template: Story = ({ extension, membership, account }: Props) => {
- const [state, setState] = useState({
- useApi,
- useMyMemberships,
- useMyAccounts,
- })
-
- useEffect(() => {
- if (extension) {
- setState({
- useApi,
- useMyMemberships,
- useMyAccounts: { ...useMyAccounts, error: 'EXTENSION' },
- })
- return
- }
-
- if (account) {
- setState({
- useApi,
- useMyMemberships,
- useMyAccounts: { ...useMyAccounts },
- })
- return
- }
-
- if (membership) {
- setState({
- useApi,
- useMyMemberships,
- useMyAccounts: { ...useMyAccounts, hasAccounts: true },
- })
- }
- }, [membership, account, extension])
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-}
-
-export const Default = Template.bind({})
-Default.args = {
- extension: true,
- account: false,
- membership: false,
-}
diff --git a/packages/ui/src/app/pages/Bounty/components/BountiesHeader.tsx b/packages/ui/src/app/pages/Bounty/components/BountiesHeader.tsx
deleted file mode 100644
index 14c3868439..0000000000
--- a/packages/ui/src/app/pages/Bounty/components/BountiesHeader.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import React from 'react'
-
-import { PageHeader } from '@/app/components/PageHeader'
-import { AddBountyButton } from '@/bounty/components/modalsButtons/AddBountyButton'
-
-import { BountiesTabs } from './BountiesTabs'
-
-export const BountiesHeader = () => {
- return } buttons={} />
-}
diff --git a/packages/ui/src/app/pages/Bounty/components/BountiesLayout.tsx b/packages/ui/src/app/pages/Bounty/components/BountiesLayout.tsx
index 85acbeff7e..43fea31824 100644
--- a/packages/ui/src/app/pages/Bounty/components/BountiesLayout.tsx
+++ b/packages/ui/src/app/pages/Bounty/components/BountiesLayout.tsx
@@ -2,6 +2,7 @@ import React, { useRef, useState } from 'react'
import { PageLayout } from '@/app/components/PageLayout'
import { BountyEmptyFilter, BountyFilters } from '@/bounty/components/BountiesFilters'
+import { BountiesHeader } from '@/bounty/components/BountiesHeader'
import { BountiesList } from '@/bounty/components/BountiesList'
import { BountyStatus, QueryExtraFilter, useBounties } from '@/bounty/hooks/useBounties'
import { BountyOrderByInput } from '@/common/api/queries'
@@ -11,8 +12,6 @@ import { MainPanel } from '@/common/components/page/PageContent'
import { Pagination } from '@/common/components/Pagination'
import { useSort } from '@/common/hooks/useSort'
-import { BountiesHeader } from './BountiesHeader'
-
export interface LayoutProps {
tilesComponent?: React.ReactNode
extraFilter?: QueryExtraFilter
diff --git a/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx
new file mode 100644
index 0000000000..20eac6baec
--- /dev/null
+++ b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx
@@ -0,0 +1,1412 @@
+import { OpeningMetadata } from '@joystream/metadata-protobuf'
+import { linkTo } from '@storybook/addon-links'
+import { expect, jest } from '@storybook/jest'
+import { Meta, ReactRenderer, StoryContext, StoryObj } from '@storybook/react'
+import { userEvent, waitFor, waitForElementToBeRemoved, within } from '@storybook/testing-library'
+import { PlayFunction, PlayFunctionContext, StepFunction } from '@storybook/types'
+import { FC } from 'react'
+
+import { metadataFromBytes } from '@/common/model/JoystreamNode/metadataFromBytes'
+import { GetMemberDocument, SearchMembersDocument } from '@/memberships/queries'
+import { member } from '@/mocks/data/members'
+import { generateProposals, MAX_ACTIVE_PROPOSAL, proposalsPagesChain } from '@/mocks/data/proposals'
+import {
+ Container,
+ getButtonByText,
+ getEditorByLabel,
+ isoDate,
+ joy,
+ selectFromDropdown,
+ withinModal,
+} from '@/mocks/helpers'
+import { MocksParameters } from '@/mocks/providers'
+import {
+ GetProposalsEventsDocument,
+ GetProposalVotesDocument,
+ GetProposalsCountDocument,
+ GetProposalsDocument,
+} from '@/proposals/queries'
+import {
+ GetWorkingGroupApplicationsDocument,
+ GetWorkingGroupDocument,
+ GetWorkingGroupOpeningsDocument,
+ GetWorkingGroupsDocument,
+} from '@/working-groups/queries'
+
+import { Proposals } from './Proposals'
+
+const PROPOSAL_DATA = {
+ title: 'Foo bar',
+ description: '## est minus rerum sed\n\nAssumenda et laboriosam minus accusantium. Sed in quo illum.',
+}
+
+const OPENING_DATA = {
+ id: 'storageWorkingGroup-12',
+ runtimeId: 12,
+ groupId: 'storageWorkingGroup',
+ group: {
+ name: 'storageWorkingGroup',
+ budget: '962651993476422',
+ leaderId: 'storageWorkingGroup-0',
+ },
+ type: 'LEADER',
+ stakeAmount: '2500000000000000',
+ rewardPerBlock: '1930000000',
+ createdInEvent: { inBlock: 123, createdAt: isoDate('2023/01/02') },
+ metadata: {
+ title: 'Hire Storage Working Group Lead',
+ applicationDetails: 'answers to questions',
+ shortDescription: 'Hire Storage Working Group Lead',
+ description: 'Lorem ipsum...',
+ hiringLimit: 1,
+ expectedEnding: null,
+ },
+ status: { __typename: 'OpeningStatusOpen' },
+}
+
+type Args = {
+ isCouncilMember: boolean
+ proposalCount: number
+ onAddStakingAccountCandidate: jest.Mock
+ onConfirmStakingAccount: jest.Mock
+ onCreateProposal: jest.Mock
+ onChangeThreadMode: jest.Mock
+ onVote: jest.Mock
+}
+type Story = StoryObj>
+
+export default {
+ title: 'Pages/Proposals/ProposalList/Current',
+ component: Proposals,
+
+ argTypes: {
+ proposalCount: { control: { type: 'range', max: MAX_ACTIVE_PROPOSAL } },
+ onAddStakingAccountCandidate: { action: 'Members.StakingAccountAdded' },
+ onConfirmStakingAccount: { action: 'Members.StakingAccountConfirmed' },
+ onCreateProposal: { action: 'ProposalsCodex.ProposalCreated' },
+ onChangeThreadMode: { action: 'proposalsDiscussion.ThreadModeChanged' },
+ onVote: { action: 'ProposalsEngine.Voted' },
+ },
+
+ args: {
+ isCouncilMember: false,
+ proposalCount: 15,
+ },
+
+ parameters: {
+ router: {
+ href: '/proposals/current',
+ actions: {
+ '/proposals/past': linkTo('Pages/Proposals/ProposalList/Past'),
+ },
+ },
+
+ isLoggedIn: true,
+ balance: 100,
+
+ stakingAccountIdMemberStatus: {
+ memberId: 0,
+ confirmed: { isTrue: true },
+ size: 1,
+ },
+
+ mocks: ({ args, parameters }: StoryContext): MocksParameters => {
+ const alice = member('alice', { isCouncilMember: args.isCouncilMember })
+
+ const forumWG = {
+ id: 'forumWorkingGroup',
+ name: 'forumWorkingGroup',
+ budget: joy(100),
+ workers: [{ stake: joy(parameters.wgLeadStake ?? 0) }, { stake: joy(50) }],
+ leader: parameters.wgLeadStake
+ ? {
+ id: 'forumWorkingGroup-10',
+ runtimeId: '10',
+ stake: joy(parameters.wgLeadStake),
+ rewardPerBlock: joy(5),
+ membershipId: alice.id,
+ isActive: true,
+ }
+ : undefined,
+ }
+ const storageWG = { id: 'storageWorkingGroup', name: 'storageWorkingGroup', budget: joy(100), workers: [] }
+
+ return {
+ accounts: parameters.isLoggedIn
+ ? { active: { member: alice, balances: parameters.balance } }
+ : { list: [{ member: alice }] },
+
+ chain: proposalsPagesChain(
+ {
+ activeProposalCount: args.proposalCount,
+ minimumValidatorCount: parameters.minimumValidatorCount,
+ setMaxValidatorCountProposalMaxValidators: parameters.setMaxValidatorCountProposalMaxValidators,
+ initialInvitationCount: parameters.initialInvitationCount,
+ initialInvitationBalance: parameters.initialInvitationBalance,
+
+ councilSize: parameters.councilSize,
+ councilBudget: parameters.councilBudget,
+ councilorReward: parameters.councilorReward,
+ nextRewardPayments: parameters.nextRewardPayments,
+
+ onAddStakingAccountCandidate: args.onAddStakingAccountCandidate,
+ onConfirmStakingAccount: args.onConfirmStakingAccount,
+ onCreateProposal: args.onCreateProposal,
+ onChangeThreadMode: args.onChangeThreadMode,
+
+ addStakingAccountCandidateFailure: parameters.addStakingAccountCandidateFailure,
+ confirmStakingAccountFailure: parameters.confirmStakingAccountFailure,
+ createProposalFailure: parameters.createProposalFailure,
+ changeThreadModeFailure: parameters.changeThreadModeFailure,
+ },
+ {
+ query: {
+ members: {
+ stakingAccountIdMemberStatus: parameters.stakingAccountIdMemberStatus,
+ },
+ },
+ tx: {
+ proposalsEngine: {
+ vote: { event: 'Voted', onSend: args.onVote },
+ },
+ },
+ }
+ ),
+
+ queryNode: [
+ {
+ query: GetProposalsCountDocument,
+ data: { proposalsConnection: { totalCount: args.proposalCount } },
+ },
+
+ {
+ query: GetProposalsDocument,
+ resolver: ({ variables } = {}) => ({
+ loading: false,
+ data: {
+ proposals: generateProposals(
+ {
+ title: PROPOSAL_DATA.title,
+ description: PROPOSAL_DATA.description,
+ creator: alice,
+ statuses: ['ProposalStatusGracing', 'ProposalStatusDormant', 'ProposalStatusDeciding'],
+ limit: variables?.limit,
+ offset: variables?.offset,
+ },
+ args.proposalCount
+ ),
+ },
+ }),
+ },
+
+ {
+ query: GetProposalVotesDocument,
+ data: {
+ proposalVotedEvents: [],
+ },
+ },
+
+ {
+ query: GetProposalsEventsDocument,
+ data: { events: [] },
+ },
+
+ {
+ query: SearchMembersDocument,
+ data: {
+ memberships: [alice],
+ },
+ },
+ {
+ query: GetMemberDocument,
+ data: {
+ membershipByUniqueInput: alice,
+ },
+ },
+
+ {
+ query: GetWorkingGroupsDocument,
+ data: {
+ workingGroups: [forumWG, storageWG],
+ },
+ },
+ {
+ query: GetWorkingGroupDocument,
+ data: {
+ workingGroupByUniqueInput: forumWG,
+ },
+ },
+ {
+ query: GetWorkingGroupOpeningsDocument,
+ data: {
+ workingGroupOpenings: [OPENING_DATA],
+ },
+ },
+ {
+ query: GetWorkingGroupApplicationsDocument,
+ data: {
+ workingGroupApplications: [
+ {
+ id: 'storageWorkingGroup-15',
+ runtimeId: 15,
+ opening: OPENING_DATA,
+ answers: [
+ { answer: 'Foo', question: { question: 'š?' } },
+ { answer: 'Bar', question: { question: 'š?' } },
+ ],
+ status: { __typename: 'ApplicationStatusPending' },
+ applicant: alice,
+ createdInEvent: { inBlock: 234, createdAt: isoDate('2023/01/04') },
+ },
+ ],
+ },
+ },
+ ],
+ }
+ },
+ },
+} satisfies Meta
+
+export const Default: Story = {}
+
+// ----------------------------------------------------------------------------
+// Create proposal: Happy case
+// ----------------------------------------------------------------------------
+
+const alice = member('alice')
+const waitForModal = (modal: Container, name: string) => modal.findByRole('heading', { name })
+
+const fillSetReferralCutStep = async (modal: Container, step: StepFunction) => {
+ await step('Specific parameters', async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ await userEvent.type(modal.getByTestId('amount-input'), '40')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.click(nextButton)
+ })
+}
+
+export const AddNewProposalHappy: Story = {
+ parameters: {
+ isLoggedIn: false,
+
+ stakingAccountIdMemberStatus: {
+ memberId: 0,
+ confirmed: { isTrue: false },
+ size: 0,
+ },
+ },
+
+ play: async ({ args, canvasElement, step }) => {
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+
+ const closeModal = async (heading: string | HTMLElement) => {
+ const headingElement = heading instanceof HTMLElement ? heading : modal.getByRole('heading', { name: heading })
+ await userEvent.click(headingElement.nextElementSibling as HTMLElement)
+ await userEvent.click(getButtonByText(modal, 'Close'))
+ }
+
+ await step('Select Membership Modal', async () => {
+ await userEvent.click(screen.getByText('Add new proposal'))
+ expect(modal.getByText('Select Membership'))
+ await userEvent.click(modal.getByText('alice'))
+ })
+
+ await step('Warning Modal', async () => {
+ const createProposalButton = getButtonByText(screen, 'Add new proposal')
+
+ await step('Temporarily close ', async () => {
+ await waitForModal(modal, 'Caution')
+
+ const nextButton = getButtonByText(modal, 'Create A Proposal')
+ expect(nextButton).toBeDisabled()
+ await userEvent.click(
+ modal.getByLabelText("I'm aware of the possible risks associated with creating a proposal.")
+ )
+ await userEvent.click(nextButton)
+
+ await closeModal('Creating new proposal')
+
+ expect(localStorage.getItem('proposalCaution')).toBe(null)
+ })
+
+ await step('Permanently close ', async () => {
+ await userEvent.click(createProposalButton)
+ await waitForModal(modal, 'Caution')
+
+ const nextButton = getButtonByText(modal, 'Create A Proposal')
+ await userEvent.click(modal.getByLabelText('Do not show this message again.'))
+ expect(nextButton).toBeDisabled()
+ await userEvent.click(
+ modal.getByLabelText("I'm aware of the possible risks associated with creating a proposal.")
+ )
+ await userEvent.click(nextButton)
+
+ await closeModal('Creating new proposal')
+
+ await userEvent.click(createProposalButton)
+ await closeModal(await waitForModal(modal, 'Creating new proposal'))
+
+ expect(localStorage.getItem('proposalCaution')).toBe('true')
+ })
+ })
+
+ await step('General parameters', async () => {
+ let nextButton: HTMLElement
+
+ await step('Proposal type', async () => {
+ const createProposalButton = getButtonByText(screen, 'Add new proposal')
+ await userEvent.click(createProposalButton)
+ await waitForModal(modal, 'Creating new proposal')
+ nextButton = getButtonByText(modal, 'Next step')
+
+ expect(nextButton).toBeDisabled()
+ await userEvent.click(modal.getByText('Set Referral Cut'))
+ await waitFor(() => expect(nextButton).not.toBeDisabled())
+ await userEvent.click(nextButton)
+ })
+
+ await step('Staking account', async () => {
+ expect(nextButton).toBeDisabled()
+ await selectFromDropdown(modal, 'Select account for Staking', 'alice')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.click(nextButton)
+ })
+
+ await step('Proposal details', async () => {
+ const titleField = modal.getByLabelText('Proposal title')
+ const rationaleEditor = await getEditorByLabel(modal, 'Rationale')
+
+ expect(nextButton).toBeDisabled()
+
+ // Invalid title
+ rationaleEditor.setData(PROPOSAL_DATA.description)
+ await userEvent.clear(titleField)
+ await userEvent.type(
+ titleField,
+ 'Reprehenderit laborum veniam est ut magna velit velit deserunt reprehenderit dolore.'
+ )
+ const titleValidation = await modal.findByText('Title exceeds maximum length')
+ expect(nextButton).toBeDisabled()
+
+ // Invalid rational
+ await userEvent.clear(titleField)
+ await userEvent.type(titleField, PROPOSAL_DATA.title)
+ rationaleEditor.setData(PROPOSAL_DATA.description.padEnd(3002, ' baz'))
+ const rationaleValidation = await modal.findByText('Rationale exceeds maximum length')
+ expect(titleValidation).not.toBeInTheDocument()
+ expect(nextButton).toBeDisabled()
+
+ // Valid
+ rationaleEditor.setData(PROPOSAL_DATA.description)
+ await waitForElementToBeRemoved(rationaleValidation)
+ expect(nextButton).toBeEnabled()
+
+ await userEvent.click(nextButton)
+ })
+
+ await step('Trigger & Discussion', async () => {
+ await step('Trigger', async () => {
+ expect(nextButton).toBeEnabled()
+
+ await userEvent.click(modal.getByText('Yes'))
+ expect(nextButton).toBeDisabled()
+
+ const blockInput = modal.getByRole('textbox')
+
+ // Invalid: too low
+ await userEvent.type(blockInput, '10')
+ expect(await modal.findByText(/The minimum block number is \d+/))
+ expect(nextButton).toBeDisabled()
+
+ // Invalid: too high
+ await userEvent.type(blockInput, '999999999')
+ await waitFor(() => expect(modal.queryByText(/The minimum block number is \d+/)).toBeNull())
+ expect(await modal.findByText(/The maximum block number is \d+/))
+ expect(nextButton).toBeDisabled()
+
+ // Valid
+ await userEvent.clear(blockInput)
+ await userEvent.type(blockInput, '9999')
+ await waitFor(() => expect(modal.queryByText(/The maximum block number is \d+/)).toBeNull())
+ expect(await modal.findByText(/^ā.*/))
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ })
+
+ await step('Discussion Mode', async () => {
+ await userEvent.click(modal.getByText('Closed'))
+
+ await waitFor(() => expect(nextButton).toBeDisabled())
+ await selectFromDropdown(modal, 'Add member to whitelist', 'alice')
+
+ expect(await modal.findByText('alice'))
+ expect(nextButton).toBeEnabled()
+
+ userEvent.click(screen.getByTestId('removeMember'))
+ expect(modal.queryByText('alice')).toBeNull()
+ await waitFor(() => expect(nextButton).toBeEnabled())
+
+ await userEvent.click(nextButton)
+
+ expect(modal.getByText('Specific parameters', { selector: 'h4' }))
+ })
+
+ await fillSetReferralCutStep(modal, step)
+ })
+
+ await step('Bind Staking Account', async () => {
+ expect(modal.getByText('You intend to bind account for staking'))
+ expect(modal.getAllByText('alice')).toHaveLength(2)
+ await userEvent.click(modal.getByText('Sign transaction and Bind Staking Account'))
+ })
+
+ await step('Sign Create Proposal transaction', async () => {
+ expect(await modal.findByText('You intend to create a proposal.'))
+ await userEvent.click(modal.getByText('Sign transaction and Create'))
+ })
+
+ await step('Sign set discussion mode transaction', async () => {
+ expect(await modal.findByText('You intend to change the proposal discussion thread mode.'))
+ await userEvent.click(modal.getByText('Sign transaction and change mode'))
+ expect(await waitForModal(modal, 'Success'))
+ })
+
+ step('Transaction parameters', () => {
+ expect(args.onAddStakingAccountCandidate).toHaveBeenCalledWith(alice.id)
+
+ expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, alice.controllerAccount)
+
+ const [generalParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(generalParameters).toEqual({
+ memberId: alice.id,
+ title: PROPOSAL_DATA.title,
+ description: PROPOSAL_DATA.description,
+ stakingAccountId: alice.controllerAccount,
+ exactExecutionBlock: 9999,
+ })
+
+ const changeModeTxParams = args.onChangeThreadMode.mock.calls.at(-1)
+ expect(changeModeTxParams.length).toBe(3)
+ const [memberId, threadId, mode] = changeModeTxParams
+ expect(memberId).toBe(alice.id)
+ expect(typeof threadId).toBe('number')
+ expect(mode.toJSON()).toEqual({ closed: [] })
+ })
+ })
+ },
+}
+
+// ----------------------------------------------------------------------------
+// Create proposal: Failure cases
+// ----------------------------------------------------------------------------
+
+export const NotEnoughFunds: Story = {
+ parameters: { balance: 1 },
+
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+ await userEvent.click(screen.getByText('Add new proposal'))
+ expect(
+ await modal.findByText(
+ /^Unfortunately the account associated with the currently selected membership has insufficient balance/
+ )
+ )
+ expect(modal.getByText('Move funds'))
+ },
+}
+
+const fillGeneralParameters = async (
+ modal: Container,
+ step: StepFunction,
+ proposalType: string,
+ closeDiscussion = false
+) => {
+ let nextButton: HTMLElement
+
+ await step('Fill General Parameters', async () => {
+ await step('Proposal type', async () => {
+ await waitForModal(modal, 'Creating new proposal')
+ nextButton = getButtonByText(modal, 'Next step')
+
+ await userEvent.click(modal.getByText(proposalType))
+ await waitFor(() => expect(nextButton).not.toBeDisabled())
+ await userEvent.click(nextButton)
+ })
+
+ await step('Staking account', async () => {
+ await selectFromDropdown(modal, 'Select account for Staking', 'alice')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.click(nextButton)
+ })
+
+ await step('Proposal details', async () => {
+ const rationaleEditor = await getEditorByLabel(modal, 'Rationale')
+ await userEvent.type(modal.getByLabelText('Proposal title'), PROPOSAL_DATA.title)
+ rationaleEditor.setData(PROPOSAL_DATA.description)
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.click(nextButton)
+ })
+
+ await step('Trigger & Discussion', async () => {
+ if (closeDiscussion) await userEvent.click(modal.getByText('Closed'))
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.click(nextButton)
+ })
+ })
+}
+
+const completeForms = async (canvasElement: HTMLElement, step: StepFunction) => {
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+
+ localStorage.setItem('proposalCaution', 'true')
+ await userEvent.click(getButtonByText(screen, 'Add new proposal'))
+ await fillGeneralParameters(modal, step, 'Set Referral Cut', true)
+ await fillSetReferralCutStep(modal, step)
+}
+
+export const BindAccountFailure: Story = {
+ parameters: {
+ stakingAccountIdMemberStatus: {
+ memberId: 0,
+ confirmed: { isTrue: false },
+ size: 0,
+ },
+ addStakingAccountCandidateFailure: 'It failed š',
+ },
+
+ play: async ({ args, canvasElement, step }) => {
+ const modal = withinModal(canvasElement)
+ await completeForms(canvasElement, step)
+ await userEvent.click(modal.getByText('Sign transaction and Bind Staking Account'))
+
+ expect(await modal.findByText('It failed š'))
+ within(document.body).getByText('Transaction failed')
+
+ expect(args.onAddStakingAccountCandidate).toHaveBeenCalled()
+ expect(args.onConfirmStakingAccount).not.toHaveBeenCalled()
+ expect(args.onCreateProposal).not.toHaveBeenCalled()
+ expect(args.onChangeThreadMode).not.toHaveBeenCalled()
+ },
+}
+
+export const BindAccountThenCreateProposalFailure: Story = {
+ parameters: {
+ stakingAccountIdMemberStatus: {
+ memberId: 0,
+ confirmed: { isTrue: false },
+ size: 0,
+ },
+ createProposalFailure: 'It failed š',
+ },
+
+ play: async ({ args, canvasElement, step }) => {
+ const modal = withinModal(canvasElement)
+ await completeForms(canvasElement, step)
+ await userEvent.click(modal.getByText('Sign transaction and Bind Staking Account'))
+ await userEvent.click(await modal.findByText('Sign transaction and Create'))
+
+ expect(await modal.findByText('It failed š'))
+ within(document.body).getByText('Transaction failed')
+
+ expect(args.onAddStakingAccountCandidate).toHaveBeenCalled()
+ expect(args.onConfirmStakingAccount).toHaveBeenCalled()
+ expect(args.onCreateProposal).toHaveBeenCalled()
+ expect(args.onChangeThreadMode).not.toHaveBeenCalled()
+ },
+}
+
+export const ConfirmAccountThenCreateProposalFailure: Story = {
+ parameters: {
+ stakingAccountIdMemberStatus: {
+ memberId: 0,
+ confirmed: { isTrue: false },
+ size: 1,
+ },
+ createProposalFailure: 'It failed š',
+ },
+
+ play: async ({ args, canvasElement, step }) => {
+ const modal = withinModal(canvasElement)
+ await completeForms(canvasElement, step)
+ await userEvent.click(await modal.findByText('Sign transaction and Create'))
+
+ expect(await modal.findByText('It failed š'))
+ within(document.body).getByText('Transaction failed')
+
+ expect(args.onAddStakingAccountCandidate).not.toHaveBeenCalled()
+ expect(args.onConfirmStakingAccount).toHaveBeenCalled()
+ expect(args.onCreateProposal).toHaveBeenCalled()
+ expect(args.onChangeThreadMode).not.toHaveBeenCalled()
+ },
+}
+
+export const CreateProposalFailure: Story = {
+ parameters: {
+ createProposalFailure: 'It failed š',
+ },
+
+ play: async ({ args, canvasElement, step }) => {
+ const modal = withinModal(canvasElement)
+ await completeForms(canvasElement, step)
+ await userEvent.click(await modal.findByText('Sign transaction and Create'))
+
+ expect(await modal.findByText('It failed š'))
+ within(document.body).getByText('Transaction failed')
+
+ expect(args.onAddStakingAccountCandidate).not.toHaveBeenCalled()
+ expect(args.onConfirmStakingAccount).not.toHaveBeenCalled()
+ expect(args.onCreateProposal).toHaveBeenCalled()
+ expect(args.onChangeThreadMode).not.toHaveBeenCalled()
+ },
+}
+
+export const ChangeThreadModeFailure: Story = {
+ parameters: {
+ changeThreadModeFailure: 'It failed š',
+ },
+
+ play: async ({ args, canvasElement, step }) => {
+ const modal = withinModal(canvasElement)
+ await completeForms(canvasElement, step)
+ await userEvent.click(await modal.findByText('Sign transaction and Create'))
+ await userEvent.click(await modal.findByText('Sign transaction and change mode'))
+
+ expect(await modal.findByText('It failed š'))
+ // within(document.body).getByText('Transaction failed')
+
+ expect(args.onAddStakingAccountCandidate).not.toHaveBeenCalled()
+ expect(args.onConfirmStakingAccount).not.toHaveBeenCalled()
+ expect(args.onCreateProposal).toHaveBeenCalled()
+ expect(args.onChangeThreadMode).toHaveBeenCalled()
+ },
+}
+
+// ----------------------------------------------------------------------------
+// Create proposal: Specific parameters tests
+// ----------------------------------------------------------------------------
+
+const EXECUTION_WARNING_BOX = 'I understand the implications of overriding the execution constraints validation.'
+type SpecificParametersTestFunction = (
+ args: Pick, 'args' | 'parameters' | 'step'> & {
+ modal: Container
+ createProposal: (create: () => Promise) => Promise
+ }
+) => Promise
+const specificParametersTest =
+ (proposalType: string, specificStep: SpecificParametersTestFunction): PlayFunction =>
+ async ({ args, parameters, canvasElement, step }) => {
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+
+ const createProposal = async (create: () => Promise) => {
+ localStorage.setItem('proposalCaution', 'true')
+
+ await userEvent.click(getButtonByText(screen, 'Add new proposal'))
+
+ await fillGeneralParameters(modal, step, proposalType)
+
+ await step(`Specific parameters: ${proposalType}`, create)
+
+ await step('Sign transaction and Create', async () => {
+ await waitFor(async () => {
+ const createButton = modal.queryByText('Create proposal')
+ if (createButton) {
+ await waitFor(() => expect(createButton).toBeEnabled())
+ await userEvent.click(createButton)
+ }
+ await userEvent.click(modal.getByText('Sign transaction and Create'))
+ })
+ expect(await waitForModal(modal, 'Success'))
+ })
+ }
+
+ await specificStep({ args, parameters, createProposal, modal, step })
+ }
+
+export const SpecificParametersSignal: Story = {
+ play: specificParametersTest('Signal', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const editor = await getEditorByLabel(modal, 'Signal')
+
+ // Invalid
+ editor.setData('')
+ const validation = await modal.findByText('Field is required')
+ expect(nextButton).toBeDisabled()
+
+ // Valid
+ editor.setData('Lorem ipsum...')
+ await waitForElementToBeRemoved(validation)
+ expect(nextButton).toBeEnabled()
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toHuman()).toEqual({ Signal: 'Lorem ipsum...' })
+ })
+ }),
+}
+
+export const SpecificParametersFundingRequest: Story = {
+ play: specificParametersTest('Funding Request', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const amountField = modal.getByTestId('amount-input')
+
+ // Invalid
+ await selectFromDropdown(modal, 'Recipient account', 'alice')
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '166667')
+ expect(await modal.findByText(/Maximal amount allowed is \d+/))
+ expect(nextButton).toBeDisabled()
+
+ // Valid again
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '100')
+ await waitFor(() => expect(modal.queryByText(/Maximal amount allowed is \d+/)).toBeNull())
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({
+ fundingRequest: [{ account: alice.controllerAccount, amount: 100_0000000000 }],
+ })
+ })
+ }),
+}
+
+export const SpecificParametersSetReferralCut: Story = {
+ play: specificParametersTest('Set Referral Cut', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const amountField = modal.getByTestId('amount-input')
+
+ // Valid
+ await userEvent.type(amountField, '40')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+
+ // Invalid: creation constraints
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '200')
+ await waitFor(() => expect(nextButton).toBeDisabled())
+
+ // Execution constraints warning
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '100')
+ expect(await modal.findByText('Input must be equal or less than 50% for proposal to execute'))
+ expect(nextButton).toBeDisabled()
+ userEvent.click(modal.getByText(EXECUTION_WARNING_BOX))
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({ setReferralCut: 100 })
+ })
+ }),
+}
+
+export const SpecificParametersDecreaseWorkingGroupLeadStake: Story = {
+ parameters: { wgLeadStake: 1000 },
+
+ play: specificParametersTest('Decrease Working Group Lead Stake', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const body = within(document.body)
+
+ // WGs without a lead are disabled
+ await userEvent.click(modal.getByPlaceholderText('Select Working Group or type group name'))
+ const storageWG = body.getByText('Storage')
+ expect(storageWG.nextElementSibling?.firstElementChild?.textContent).toMatch(/This group has no lead/)
+ expect(storageWG).toHaveStyle({ 'pointer-events': 'none' })
+
+ // NOTE: This should be valid but here the button is still disabled
+ userEvent.click(body.getByText('Forum'))
+ const stakeMessage = modal.getByText(/The actual stake for Forum Working Group Lead is/)
+ expect(within(stakeMessage).getByText('1,000'))
+
+ const amountField = modal.getByTestId('amount-input')
+
+ await waitFor(() => expect(amountField).toHaveValue('500'))
+
+ // Invalid: stake set to 0
+ await userEvent.clear(amountField)
+ expect(await modal.findByText('Amount must be greater than zero'))
+ expect(nextButton).toBeDisabled()
+
+ // Valid 1/3
+ userEvent.click(modal.getByText('By 1/3'))
+ waitFor(() => expect(modal.queryByText('Amount must be greater than zero')).toBeNull())
+ expect(amountField).toHaveValue('333.3333333333')
+
+ // Valid 1/2
+ userEvent.click(modal.getByText('By half'))
+ expect(amountField).toHaveValue('500')
+ })
+
+ step('Transaction parameters', () => {
+ const leaderId = 10 // Set on the mock QN query
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({
+ decreaseWorkingGroupLeadStake: [leaderId, 500_0000000000, 'Forum'],
+ })
+ })
+ }),
+}
+
+export const SpecificParametersTerminateWorkingGroupLead: Story = {
+ parameters: { wgLeadStake: 1000 },
+
+ play: specificParametersTest('Terminate Working Group Lead', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const body = within(document.body)
+
+ // WGs without a lead are disabled
+ await userEvent.click(modal.getByPlaceholderText('Select Working Group or type group name'))
+ const storageWG = body.getByText('Storage')
+ expect(storageWG.nextElementSibling?.firstElementChild?.textContent).toMatch(/This group has no lead/)
+ expect(storageWG).toHaveStyle({ 'pointer-events': 'none' })
+
+ // Valid: Don't Slash lead
+ userEvent.click(body.getByText('Forum'))
+ expect(await modal.findByText('alice'))
+ await waitFor(() => expect(nextButton).toBeEnabled())
+
+ // Valid: Slash the lead 2000 JOY
+ userEvent.click(modal.getByText('Yes'))
+ const amountField = modal.getByTestId('amount-input')
+ expect(amountField).toHaveValue('')
+ userEvent.type(amountField, '2000')
+ })
+
+ step('Transaction parameters', () => {
+ const leaderId = 10 // Set on the mock QN query
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({
+ terminateWorkingGroupLead: {
+ workerId: leaderId,
+ slashingAmount: 2000_0000000000,
+ group: 'Forum',
+ },
+ })
+ })
+ }),
+}
+
+export const SpecificParametersCreateWorkingGroupLeadOpening: Story = {
+ parameters: { wgLeadStake: 1000 },
+
+ play: specificParametersTest('Create Working Group Lead Opening', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Next step')
+ expect(nextButton).toBeDisabled()
+
+ const body = within(document.body)
+
+ // WGs without a lead are enabled
+ await userEvent.click(modal.getByPlaceholderText('Select Working Group or type group name'))
+ const storageWG = body.getByText('Storage')
+ expect(storageWG).not.toHaveStyle({ 'pointer-events': 'none' })
+
+ // Step 1 valid
+ await userEvent.click(body.getByText('Forum'))
+ await userEvent.type(modal.getByLabelText('Opening title'), 'Foo')
+ await userEvent.type(modal.getByLabelText('Short description'), 'Bar')
+ ;(await getEditorByLabel(modal, 'Description')).setData('Baz')
+ expect(nextButton).toBeDisabled()
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.click(nextButton)
+
+ // Step 2
+ expect(nextButton).toBeDisabled()
+ ;(await getEditorByLabel(modal, 'Application process')).setData('Lorem ipsum...')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.click(modal.getByText('Limited'))
+ await waitFor(() => expect(nextButton).toBeDisabled())
+ await userEvent.type(modal.getByLabelText('Expected length of the application period'), '1000')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.click(nextButton)
+
+ // Step 3
+ expect(nextButton).toBeDisabled()
+ await userEvent.type(modal.getByRole('textbox'), 'š?')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.click(modal.getByText('Add new question'))
+ await waitFor(() => expect(nextButton).toBeDisabled())
+ await userEvent.click(modal.getAllByText('Long answer')[1])
+ await userEvent.type(modal.getAllByRole('textbox')[1], 'š?')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.click(nextButton)
+
+ // Step 4
+ expect(nextButton).toBeDisabled()
+ await userEvent.type(modal.getByLabelText('Staking amount *'), '100')
+ await userEvent.type(modal.getByLabelText('Role cooldown period'), '0')
+ await userEvent.type(modal.getByLabelText('Reward amount per Block'), '0.1')
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ const { description, ...data } = specificParameters.asCreateWorkingGroupLeadOpening.toJSON()
+
+ expect(data).toEqual({
+ rewardPerBlock: 1000000000,
+ stakePolicy: {
+ stakeAmount: 100_0000000000,
+ leavingUnstakingPeriod: 0,
+ },
+ group: 'Forum',
+ })
+
+ expect(metadataFromBytes(OpeningMetadata, description)).toEqual({
+ title: 'Foo',
+ shortDescription: 'Bar',
+ description: 'Baz',
+ hiringLimit: 1,
+ expectedEndingTimestamp: 1000,
+ applicationDetails: 'Lorem ipsum...',
+ applicationFormQuestions: [
+ { question: 'š?', type: OpeningMetadata.ApplicationFormQuestion.InputType.TEXT },
+ { question: 'š?', type: OpeningMetadata.ApplicationFormQuestion.InputType.TEXTAREA },
+ ],
+ })
+ })
+ }),
+}
+
+export const SpecificParametersSetWorkingGroupLeadReward: Story = {
+ parameters: { wgLeadStake: 1000 },
+
+ play: specificParametersTest('Set Working Group Lead Reward', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const body = within(document.body)
+
+ // WGs without a lead are disabled
+ await userEvent.click(modal.getByPlaceholderText('Select Working Group or type group name'))
+ const storageWG = body.getByText('Storage')
+ expect(storageWG.nextElementSibling?.firstElementChild?.textContent).toMatch(/This group has no lead/)
+ expect(storageWG).toHaveStyle({ 'pointer-events': 'none' })
+
+ // Valid
+ userEvent.click(body.getByText('Forum'))
+ expect(await modal.findByText('alice'))
+ const stakeMessage = modal.getByText(/Current reward per block for Forum Working Group Lead is/)
+ expect(within(stakeMessage).getByText('5'))
+ expect(nextButton).toBeDisabled()
+ const amountField = modal.getByTestId('amount-input')
+ await userEvent.type(amountField, '1')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+
+ // Invalid
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '0')
+ await waitFor(() => expect(nextButton).toBeDisabled())
+
+ // Valid again
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '10')
+ })
+
+ step('Transaction parameters', () => {
+ const leaderId = 10 // Set on the mock QN query
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({
+ setWorkingGroupLeadReward: [leaderId, 10_0000000000, 'Forum'],
+ })
+ })
+ }),
+}
+
+export const SpecificParametersSetMaxValidatorCount: Story = {
+ parameters: { minimumValidatorCount: 4, setMaxValidatorCountProposalMaxValidators: 100 },
+
+ play: specificParametersTest('Set Max Validator Count', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const amountField = modal.getByTestId('amount-input')
+
+ // Invalid: too low
+ await userEvent.type(amountField, '1')
+ const validation = await modal.findByText('Minimal amount allowed is 4')
+ expect(validation)
+ expect(nextButton).toBeDisabled()
+
+ // Invalid: too high
+ await userEvent.type(amountField, '999')
+ // console.log(validation)
+ await waitFor(() => expect(validation).toHaveTextContent('Maximal amount allowed is 100'))
+ expect(nextButton).toBeDisabled()
+
+ // Valid
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '10')
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({ setMaxValidatorCount: 10 })
+ })
+ }),
+}
+
+export const SpecificParametersCancelWorkingGroupLeadOpening: Story = {
+ play: specificParametersTest('Cancel Working Group Lead Opening', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const body = within(document.body)
+
+ // Valid
+ await userEvent.click(modal.getByPlaceholderText('Choose opening to cancel'))
+ userEvent.click(body.getByText('Hire Storage Working Group Lead'))
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({ cancelWorkingGroupLeadOpening: [12, 'Storage'] })
+ })
+ }),
+}
+
+export const SpecificParametersSetCouncilBudgetIncrement: Story = {
+ play: specificParametersTest('Set Council Budget Increment', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const amountField = modal.getByTestId('amount-input')
+
+ // Invalid budget 0
+ await userEvent.type(amountField, '1')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '0')
+ await waitFor(() => expect(nextButton).toBeDisabled())
+
+ // The value remains less than 2^128
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, ''.padEnd(39, '9'))
+ const value = Number((amountField as HTMLInputElement).value.replace(/,/g, ''))
+ expect(value).toBeLessThan(2 ** 128)
+
+ // Valid
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '500')
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({ setCouncilBudgetIncrement: 500_0000000000 })
+ })
+ }),
+}
+
+export const SpecificParametersSetCouncilorReward: Story = {
+ play: specificParametersTest('Set Councilor Reward', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const amountField = modal.getByTestId('amount-input')
+
+ // Invalid budget 0
+ await userEvent.type(amountField, '1')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '0')
+ await waitFor(() => expect(nextButton).toBeDisabled())
+
+ // Valid
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '10')
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({ setCouncilorReward: 10_0000000000 })
+ })
+ }),
+}
+
+export const SpecificParametersSetMembershipLeadInvitationQuota: Story = {
+ parameters: { wgLeadStake: 1000 },
+
+ play: specificParametersTest(
+ 'Set Membership Lead Invitation Quota',
+ async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const amountField = modal.getByTestId('amount-input')
+
+ // Invalid budget 0
+ await userEvent.type(amountField, '1')
+ await waitFor(() => expect(nextButton).toBeEnabled())
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '0')
+ await waitFor(() => expect(nextButton).toBeDisabled())
+
+ // The value remains less than 2^32
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, ''.padEnd(39, '9'))
+ const value = Number((amountField as HTMLInputElement).value.replace(/,/g, ''))
+ expect(value).toBeLessThan(2 ** 32)
+
+ // Valid
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '3')
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({ setMembershipLeadInvitationQuota: 3 })
+ })
+ }
+ ),
+}
+
+export const SpecificParametersFillWorkingGroupLeadOpening: Story = {
+ play: specificParametersTest('Fill Working Group Lead Opening', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const body = within(document.body)
+
+ // Select Opening
+ await userEvent.click(modal.getByPlaceholderText('Choose opening to fill'))
+ userEvent.click(body.getByText('Hire Storage Working Group Lead'))
+
+ // Select Application
+ const applicationSelector = await modal.findByPlaceholderText('Choose application')
+ const options = await waitFor(async () => {
+ await userEvent.click(applicationSelector)
+ const options = document.getElementById('select-popper-wrapper')
+ expect(options).not.toBeNull()
+ return within(options as HTMLElement)
+ })
+ expect(nextButton).toBeDisabled()
+ userEvent.click(options.getByText('alice'))
+
+ // Check application
+ expect(await modal.findByText('š?'))
+ expect(modal.getByText('Foo'))
+ expect(modal.getByText('š?'))
+ expect(modal.getByText('Bar'))
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({
+ fillWorkingGroupLeadOpening: {
+ applicationId: 15,
+ openingId: 12,
+ workingGroup: 'Storage',
+ },
+ })
+ })
+ }),
+}
+
+export const SpecificParametersSetInitialInvitationCount: Story = {
+ parameters: { initialInvitationCount: 5 },
+
+ play: specificParametersTest('Set Initial Invitation Count', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ expect(modal.getByText('The current initial invitation count is 5.'))
+
+ const countField = modal.getByLabelText('New Count')
+
+ // Invalid 0 invitations
+ await userEvent.type(countField, '0')
+ expect(await modal.findByText('Amount must be greater than zero'))
+ expect(nextButton).toBeDisabled()
+
+ // The value remains less than 2^32
+ await userEvent.clear(countField)
+ await userEvent.type(countField, ''.padEnd(39, '9'))
+ const value = Number((countField as HTMLInputElement).value.replace(/,/g, ''))
+ expect(value).toBeLessThan(2 ** 32)
+
+ // Valid
+ await userEvent.clear(countField)
+ await userEvent.type(countField, '7')
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({ setInitialInvitationCount: 7 })
+ })
+ }),
+}
+
+export const SpecificParametersSetInitialInvitationBalance: Story = {
+ parameters: { initialInvitationBalance: joy(5) },
+
+ play: specificParametersTest('Set Initial Invitation Balance', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const current = modal.getByText(/The current balance is/)
+ expect(within(current).getByText('5'))
+
+ const amountField = modal.getByTestId('amount-input')
+
+ // Invalid balance 0
+ await userEvent.type(amountField, '0')
+ expect(await modal.findByText('Amount must be greater than zero'))
+ expect(nextButton).toBeDisabled()
+
+ // Valid
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '7')
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({ setInitialInvitationBalance: 7_0000000000 })
+ })
+ }),
+}
+
+export const SpecificParametersSetMembershipPrice: Story = {
+ play: specificParametersTest('Set Membership Price', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const amountField = modal.getByTestId('amount-input')
+
+ // Invalid price set to 0
+ await userEvent.type(amountField, '0')
+ expect(await modal.findByText('Amount must be greater than zero'))
+ expect(nextButton).toBeDisabled()
+
+ // Valid
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '8')
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({ setMembershipPrice: 8_0000000000 })
+ })
+ }),
+}
+
+export const SpecificParametersUpdateWorkingGroupBudget: Story = {
+ parameters: {
+ councilSize: 3,
+ councilBudget: joy(2000),
+ councilorReward: joy(100),
+ nextRewardPayments: 12345,
+ },
+
+ play: specificParametersTest('Update Working Group Budget', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const amountField = modal.getByTestId('amount-input')
+
+ const currentCouncilBudget = modal.getByText(/Current budget for Council is/)
+ expect(within(currentCouncilBudget).getByText('2,000'))
+
+ const councilSummary = modal.getByText(/Next Council payment is in/)
+ expect(within(councilSummary).getByText('12,345')) // Next reward payment block
+ expect(within(councilSummary).getByText(100 * 3)) // Next reward payment block
+
+ expect(
+ modal.getByText(
+ 'If the Councils budget is less than provided amount at attempted execution, this proposal will fail to execute, and the budget size will not be changed.'
+ )
+ )
+
+ userEvent.click(modal.getByText('Yes'))
+ expect(
+ modal.getByText(
+ 'If the budget is less than provided amount at attempted execution, this proposal will fail to execute and the budget size will not be changed'
+ )
+ )
+
+ // Select working group
+ await userEvent.click(modal.getByPlaceholderText('Select Working Group or type group name'))
+ userEvent.click(within(document.body).getByText('Forum'))
+
+ const currentWgBudget = modal.getByText(/Current budget for Forum Working Group is/)
+ expect(within(currentWgBudget).getByText('100'))
+
+ // Invalid price set to 0
+ await userEvent.type(amountField, '0')
+ expect(await modal.findByText('Amount must be greater than zero'))
+ expect(nextButton).toBeDisabled()
+
+ // Valid
+ await userEvent.clear(amountField)
+ await userEvent.type(amountField, '99')
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({
+ updateWorkingGroupBudget: [99_0000000000, 'Forum', 'Negative'],
+ })
+ })
+ }),
+}
+
+export const SpecificParametersRuntimeUpgrade: Story = {
+ play: specificParametersTest('Runtime Upgrade', async ({ args, createProposal, modal, step }) => {
+ await createProposal(async () => {
+ const nextButton = getButtonByText(modal, 'Create proposal')
+ expect(nextButton).toBeDisabled()
+
+ const uploadField = modal.getByTestId('runtime-upgrade-input')
+
+ // Invalid
+ await userEvent.upload(uploadField, new File([], 'invalid.wasm', { type: 'application/wasm' }))
+ const validation = await modal.findByText(/was not loaded because of: "not valid WASM file"./)
+ expect(within(validation).getByText('invalid.wasm'))
+
+ // Valid
+ const setIsValidWASM = jest.fn()
+ const validFile = Object.defineProperties(new File([], 'valid.wasm', { type: 'application/wasm' }), {
+ isValidWASM: { get: () => true, set: setIsValidWASM },
+ arrayBuffer: { value: () => Promise.resolve(new ArrayBuffer(1)) },
+ size: { value: 1 },
+ })
+ await userEvent.upload(uploadField, validFile)
+ await waitFor(() => expect(setIsValidWASM).toHaveBeenCalledWith(false))
+ const confirmation = await modal.findByText(/was loaded successfully!/)
+ expect(within(confirmation).getByText('valid.wasm'))
+ })
+
+ step('Transaction parameters', () => {
+ const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1)
+ expect(specificParameters.toJSON()).toEqual({ runtimeUpgrade: '0x' })
+ })
+ }),
+}
diff --git a/packages/ui/src/app/pages/Proposals/PastProposals.stories.tsx b/packages/ui/src/app/pages/Proposals/PastProposals.stories.tsx
new file mode 100644
index 0000000000..b9a06ab4fd
--- /dev/null
+++ b/packages/ui/src/app/pages/Proposals/PastProposals.stories.tsx
@@ -0,0 +1,91 @@
+import { linkTo } from '@storybook/addon-links'
+import { Meta, StoryContext, StoryObj } from '@storybook/react'
+import { random } from 'faker'
+import { FC } from 'react'
+
+import { member } from '@/mocks/data/members'
+import { generateProposals, proposalsPagesChain } from '@/mocks/data/proposals'
+import { MocksParameters } from '@/mocks/providers'
+import { GetProposalsCountDocument, GetProposalsDocument } from '@/proposals/queries'
+
+import { PastProposals } from './PastProposals'
+
+import { randomMarkdown } from '@/../dev/query-node-mocks/generators/utils'
+
+const PROPOSAL_DATA = {
+ title: random.words(4),
+ description: randomMarkdown(),
+}
+
+const alice = member('alice')
+
+type Args = {
+ proposalCount: number
+}
+type Story = StoryObj>
+
+export default {
+ title: 'Pages/Proposals/ProposalList/Past',
+ component: PastProposals,
+
+ argTypes: {
+ proposalCount: { control: { type: 'range', max: 30 } },
+ },
+
+ args: {
+ proposalCount: 15,
+ },
+
+ parameters: {
+ router: {
+ href: '/proposals/past',
+ actions: {
+ '/proposals/current': linkTo('Pages/Proposals/ProposalList/Current'),
+ },
+ },
+
+ mocks: ({ args }: StoryContext): MocksParameters => {
+ return {
+ chain: proposalsPagesChain({ activeProposalCount: 5 }),
+
+ queryNode: [
+ {
+ query: GetProposalsCountDocument,
+ data: { proposalsConnection: { totalCount: args.proposalCount } },
+ },
+
+ {
+ query: GetProposalsDocument,
+ resolver: ({ variables } = {}) => ({
+ loading: false,
+ data: {
+ proposals: generateProposals(
+ {
+ title: PROPOSAL_DATA.title,
+ description: PROPOSAL_DATA.description,
+ creator: alice,
+ statuses: [
+ 'ProposalStatusCanceledByRuntime',
+ 'ProposalStatusCancelled',
+ 'ProposalStatusExecuted',
+ 'ProposalStatusExecutionFailed',
+ 'ProposalStatusExpired',
+ 'ProposalStatusRejected',
+ 'ProposalStatusSlashed',
+ 'ProposalStatusVetoed',
+ ],
+ limit: variables?.limit,
+ offset: variables?.offset,
+ },
+ args.proposalCount
+ ),
+ },
+ }),
+ },
+ ],
+ }
+ },
+ },
+} satisfies Meta
+
+export const Default: Story = {}
diff --git a/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx b/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx
index 107b297098..1e9bf67846 100644
--- a/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx
+++ b/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx
@@ -1,4 +1,4 @@
-import { expect } from '@storybook/jest'
+import { expect, jest } from '@storybook/jest'
import { Meta, StoryContext, StoryObj } from '@storybook/react'
import { userEvent, within } from '@storybook/testing-library'
import { random } from 'faker'
@@ -6,7 +6,6 @@ import { last } from 'lodash'
import { FC } from 'react'
import { ProposalVoteKind } from '@/common/api/queries'
-import { RecursivePartial } from '@/common/types/helpers'
import { repeat } from '@/common/utils'
import { GetElectedCouncilDocument } from '@/council/queries'
import { member } from '@/mocks/data/members'
@@ -14,12 +13,13 @@ import {
ProposalStatus,
proposalDiscussionPosts,
proposalActiveStatus,
- proposalDetailsMap,
+ generateProposal,
+ proposalTypes,
} from '@/mocks/data/proposals'
-import { isoDate, joy, merge } from '@/mocks/helpers'
+import { getButtonByText, getEditorByLabel, withinModal, isoDate, joy, Container } from '@/mocks/helpers'
import { ProposalDetailsType, proposalDetailsToConstantKey } from '@/mocks/helpers/proposalDetailsToConstantKey'
import { MocksParameters } from '@/mocks/providers'
-import { GetProposalDocument, ProposalWithDetailsFieldsFragment } from '@/proposals/queries'
+import { GetProposalDocument } from '@/proposals/queries'
import { ProposalPreview } from './ProposalPreview'
@@ -29,7 +29,7 @@ const bob = member('bob', { isCouncilMember: true })
const charlie = member('charlie', { isCouncilMember: true })
const PROPOSAL_DATA = {
- id: '0',
+ id: '123',
title: random.words(4),
description: randomMarkdown(),
}
@@ -49,6 +49,7 @@ type Args = {
vote1: VoteArg
vote2: VoteArg
vote3: VoteArg
+ onVote: jest.Mock
}
type Story = StoryObj>
@@ -57,11 +58,12 @@ export default {
component: ProposalPreview,
argTypes: {
- type: { control: { type: 'select' }, options: Object.keys(proposalDetailsMap) },
+ type: { control: { type: 'select' }, options: proposalTypes },
constitutionality: { control: { type: 'range', min: 1, max: 4 } },
vote1: { control: { type: 'inline-radio' }, options: voteArgs },
vote2: { control: { type: 'inline-radio' }, options: voteArgs },
vote3: { control: { type: 'inline-radio' }, options: voteArgs },
+ onVote: { action: 'ProposalsEngine.Voted' },
},
args: {
@@ -77,7 +79,10 @@ export default {
},
parameters: {
- router: { path: '/:id', href: '/0' },
+ router: { path: '/:id', href: `/${PROPOSAL_DATA.id}` },
+
+ statuses: ['ProposalStatusDeciding'] satisfies ProposalStatus[],
+ totalBalance: 100,
mocks: ({ args, parameters }: StoryContext): MocksParameters => {
const { constitutionality, isCouncilMember } = args
@@ -98,7 +103,7 @@ export default {
)
return {
- accounts: { active: alice },
+ accounts: { active: { member: alice, balances: parameters.totalBalance } },
chain: {
consts: {
@@ -127,16 +132,30 @@ export default {
},
referendum: { stage: {} },
},
+
+ tx: {
+ proposalsEngine: {
+ vote: {
+ event: 'Voted',
+ onSend: args.onVote,
+ failure: parameters.txFailure,
+ },
+ },
+ },
},
queryNode: [
{
query: GetProposalDocument,
data: {
- proposal: {
+ proposal: generateProposal({
id: PROPOSAL_DATA.id,
title: PROPOSAL_DATA.title,
description: PROPOSAL_DATA.description,
+ status,
+ type: args.type,
+ creator: args.isProposer ? alice : bob,
+
discussionThread: {
posts: proposalDiscussionPosts,
mode: args.isDiscussionOpen
@@ -150,22 +169,15 @@ export default {
},
},
- creator: args.isProposer ? alice : bob,
- details: proposalDetailsMap[args.type],
-
- createdInEvent: { inBlock: 123, createdAt: isoDate('2023/01/02') },
proposalStatusUpdates: updates.map((status: ProposalStatus) => ({
inBlock: 123,
createdAt: isoDate('2023/01/02'),
newStatus: { __typename: status },
})),
- status: { __typename: status },
- statusSetAtBlock: 123,
- statusSetAtTime: isoDate('2023/01/12'),
councilApprovals: parameters.councilApprovals ?? constitutionality - 1,
votes,
- } as RecursivePartial,
+ }),
},
},
@@ -188,11 +200,13 @@ export default {
],
}
},
-
- statuses: ['ProposalStatusDeciding'] satisfies ProposalStatus[],
},
} satisfies Meta
+// ----------------------------------------------------------------------------
+// ProposalPreview
+// ----------------------------------------------------------------------------
+
export const AmendConstitution: Story = {
args: { type: 'AmendConstitutionProposalDetails', constitutionality: 2 },
parameters: {
@@ -276,47 +290,57 @@ export const Veto: Story = {
}
// ----------------------------------------------------------------------------
-// Tests
+// VoteForProposalModal
+// ----------------------------------------------------------------------------
+
+export const VoteForProposalModal: Story = {
+ args: { type: 'SignalProposalDetails', isCouncilMember: true },
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByText('Vote on Proposal'))
+ },
+}
+
+// ----------------------------------------------------------------------------
+// Test ProposalPreview
// ----------------------------------------------------------------------------
export const TestsIsNotCouncil: Story = {
- ...merge(SetMaxValidatorCount, { args: { isCouncilMember: false, isProposer: true } }),
+ args: { type: 'SetMaxValidatorCountProposalDetails', constitutionality: 2, isCouncilMember: false, isProposer: true },
- name: 'Test: Is not in council',
+ name: 'Test ProposalPreview > Is not in council',
play: async ({ canvasElement, step }) => {
const screen = within(canvasElement)
await step('Main', () => {
- expect(screen.getByText(PROPOSAL_DATA.title, { selector: 'header h2' })).toBeDefined()
+ expect(screen.getByText(PROPOSAL_DATA.title, { selector: 'header h2' }))
- expect(screen.getByText('Deciding', { selector: 'header *' })).toBeDefined()
+ expect(screen.getByText('Deciding', { selector: 'header *' }))
expect(screen.getAllByText(/(?:Approval|Slashing) (?:Quorum|Threshold)/)).toHaveLength(4)
- expect(screen.getByText('Set Max Validator Count')).toBeDefined()
+ expect(screen.getByText('Set Max Validator Count'))
- expect(screen.getByText('Rationale')).toBeDefined()
+ expect(screen.getByText('Rationale'))
- expect(screen.getByText('Discussion')).toBeDefined()
+ expect(screen.getByText('Discussion'))
})
await step('Header', () => {
- expect(screen.getByText('Round 1')).toBeVisible()
- expect(screen.getByText('Round 2')).toBeVisible()
+ expect(screen.getByText('Round 1'))
+ expect(screen.getByText('Round 2'))
})
await step('Sidebar', () => {
const sideBarElement = screen.getByRole('complementary')
- expect(sideBarElement).toBeDefined()
-
const sideBar = within(sideBarElement)
const proposerSection = within(sideBar.getByText('Proposer').parentElement as HTMLElement)
- expect(proposerSection.getByText('alice')).toBeDefined()
- expect(sideBar.getByText('History')).toBeDefined()
+ expect(proposerSection.getByText('alice'))
+
+ expect(sideBar.getByText('History'))
for (const name of ['Approved', 'Rejected', 'Slashed', 'Abstained', 'Not Voted']) {
- expect(sideBar.getByText(name)).toBeDefined()
+ expect(sideBar.getByText(name))
}
})
@@ -328,63 +352,251 @@ export const TestsIsNotCouncil: Story = {
}
export const TestsHasNotVoted: Story = {
- ...merge(SetMaxValidatorCount, { args: { isCouncilMember: true, isProposer: true } }),
+ args: { type: 'SetMaxValidatorCountProposalDetails', constitutionality: 2, isCouncilMember: true, isProposer: true },
- name: 'Test: Has not voted',
+ name: 'Test ProposalPreview > Has not voted',
play: async ({ canvasElement }) => {
const screen = within(canvasElement)
expect(screen.queryByText(/You voted for:/i)).toBeNull()
},
}
-export const TestsHasInCurrentRound: Story = {
- ...merge(
- { ...SetMaxValidatorCount, parameters: {} },
- {
- args: { isCouncilMember: true, isProposer: true },
- parameters: {
- statuses: ['ProposalStatusDeciding'] satisfies ProposalStatus[],
- councilApprovals: 0,
- votes: [['Reject', 'Approve', 'Approve']] satisfies VoteArg[][],
- },
- }
- ),
-
- name: 'Test: Has voted in the current round',
+export const TestsHasVotedInCurrentRound: Story = {
+ args: { type: 'SetMaxValidatorCountProposalDetails', constitutionality: 2, isCouncilMember: true, isProposer: true },
+ parameters: {
+ statuses: ['ProposalStatusDeciding'] satisfies ProposalStatus[],
+ councilApprovals: 0,
+ votes: [['Reject', 'Approve', 'Approve']] satisfies VoteArg[][],
+ },
+
+ name: 'Test ProposalPreview > Has voted in the current round',
play: async ({ canvasElement }) => {
const screen = within(canvasElement)
- expect(screen.getByText(/Already voted/i)).toBeDefined()
+ expect(screen.getByText(/Already voted/i))
expect(screen.getByText(/You voted for:/i)).toHaveTextContent('You voted for: Rejected')
},
}
-export const TestsHasNotInCurrentRound: Story = {
- ...merge(
- { ...SetMaxValidatorCount, parameters: {} },
- {
- args: { isCouncilMember: true, isProposer: true },
- parameters: {
- statuses: [
- 'ProposalStatusDeciding',
- 'ProposalStatusDormant',
- 'ProposalStatusDeciding',
- ] satisfies ProposalStatus[],
- votes: [
- ['Approve', 'Approve', 'Approve'],
- ['None', 'Reject', 'Slash'],
- ] satisfies VoteArg[][],
- },
- }
- ),
-
- name: 'Test: Not voted in the current round',
+export const TestsHasNotVotedInCurrentRound: Story = {
+ args: { type: 'SetMaxValidatorCountProposalDetails', constitutionality: 2, isCouncilMember: true, isProposer: true },
+ parameters: {
+ statuses: ['ProposalStatusDeciding', 'ProposalStatusDormant', 'ProposalStatusDeciding'] satisfies ProposalStatus[],
+ votes: [
+ ['Approve', 'Approve', 'Approve'],
+ ['None', 'Reject', 'Slash'],
+ ] satisfies VoteArg[][],
+ },
+
+ name: 'Test ProposalPreview > Not voted in the current round',
play: async ({ canvasElement }) => {
const screen = within(canvasElement)
- expect(screen.getByText(/Vote on Proposal/i)).toBeDefined()
+ expect(screen.getByText(/Vote on Proposal/i))
expect(screen.queryByText(/You voted for:/i)).toBeNull()
await userEvent.click(screen.getByText('Round 1'))
expect(screen.getByText(/You voted for:/i)).toHaveTextContent('You voted for: Approved')
},
}
+
+// ----------------------------------------------------------------------------
+// VoteForProposalModal
+// ----------------------------------------------------------------------------
+
+const fillRationale = async (modal: Container): Promise =>
+ (await getEditorByLabel(modal, /Rationale/i)).setData('Some rationale')
+
+export const TestVoteHappy: Story = {
+ args: { type: 'SignalProposalDetails', isCouncilMember: true },
+
+ name: 'Test VoteForProposalModal Happy cases',
+
+ play: async ({ canvasElement, step, args: { onVote } }) => {
+ const activeMember = member('alice')
+
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+ const getButton = (text: string | RegExp) => getButtonByText(modal, text)
+
+ await step('Approve', async () => {
+ await step('Form', async () => {
+ await userEvent.click(screen.getByText('Vote on Proposal'))
+ expect(await modal.findByText('Vote for proposal'))
+ expect(modal.getByText(PROPOSAL_DATA.title))
+
+ expect(getButton(/^Reject/i))
+ expect(getButton(/^Approve/i))
+ expect(getButton(/^Abstain/i))
+
+ const rationaleEditor = await getEditorByLabel(modal, /Rationale/i)
+ const nextButton = getButton(/^sign transaction and vote/i)
+ expect(nextButton).toBeDisabled()
+
+ rationaleEditor.setData('Some rationale')
+ expect(nextButton).toBeDisabled()
+ rationaleEditor.setData('')
+
+ await userEvent.click(getButton(/^Approve/i))
+ expect(nextButton).toBeDisabled()
+
+ rationaleEditor.setData('Some rationale')
+ expect(nextButton).toBeEnabled()
+
+ await userEvent.click(nextButton)
+ })
+
+ await step('Sign', async () => {
+ expect(modal.getByText('Authorize transaction'))
+ const signText = modal.getByText(RegExp(`^You intend to .+ "${PROPOSAL_DATA.title}"\\.$`))
+ expect(within(signText).getByText('Approve'))
+ expect(modal.queryByText(/^(.*?)You need at least \d+ tJOY(.*)/i)).toBeNull()
+
+ await userEvent.click(modal.getByText(/^Sign transaction and Vote/))
+ })
+
+ await step('Confirm', async () => {
+ const confirmText = await modal.findByText(
+ RegExp(`^You have just successfully .+ \\W${PROPOSAL_DATA.title}\\W\\.$`)
+ )
+ expect(within(confirmText).getByText('Approve'))
+
+ expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Approve', 'Some rationale')
+
+ await userEvent.click(modal.getByText('Back to proposals'))
+ })
+ })
+
+ await step('Reject', async () => {
+ await step('Form', async () => {
+ await userEvent.click(screen.getByText('Vote on Proposal'))
+ expect(await modal.findByText('Vote for proposal'))
+
+ const nextButton = getButton(/^sign transaction and vote/i)
+
+ await userEvent.click(getButton(/^Reject/i))
+ expect(nextButton).toBeDisabled()
+
+ await fillRationale(modal)
+ await userEvent.click(nextButton)
+ })
+
+ await step('Sign', async () => {
+ expect(modal.getByText('Authorize transaction'))
+ const signText = modal.getByText(RegExp(`^You intend to .+ "${PROPOSAL_DATA.title}"\\.$`))
+ expect(within(signText).getByText('Reject'))
+
+ await userEvent.click(modal.getByText(/^Sign transaction and Vote/))
+ })
+
+ await step('Confirm', async () => {
+ const confirmText = await modal.findByText(
+ RegExp(`^You have just successfully .+ \\W${PROPOSAL_DATA.title}\\W\\.$`)
+ )
+ expect(within(confirmText).getByText('Reject'))
+
+ expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Reject', 'Some rationale')
+
+ await userEvent.click(modal.getByText('Back to proposals'))
+ })
+ })
+
+ await step('Slash', async () => {
+ await step('Form', async () => {
+ await userEvent.click(screen.getByText('Vote on Proposal'))
+ expect(await modal.findByText('Vote for proposal'))
+
+ const nextButton = getButton(/^sign transaction and vote/i)
+
+ await userEvent.click(getButton(/^Reject/i))
+ const slashToggle = modal.getByLabelText('Slash Proposal')
+
+ userEvent.click(slashToggle)
+ expect(nextButton).toBeDisabled()
+
+ await fillRationale(modal)
+ await userEvent.click(nextButton)
+ })
+
+ await step('Sign', async () => {
+ expect(modal.getByText('Authorize transaction'))
+ const signText = modal.getByText(RegExp(`^You intend to .+ "${PROPOSAL_DATA.title}"\\.$`))
+ expect(within(signText).getByText('Slash'))
+ await userEvent.click(modal.getByText(/^Sign transaction and Vote/))
+ })
+
+ await step('Confirm', async () => {
+ const confirmText = await modal.findByText(
+ RegExp(`^You have just successfully .+ \\W${PROPOSAL_DATA.title}\\W\\.$`)
+ )
+ expect(within(confirmText).getByText('Slash'))
+
+ expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Slash', 'Some rationale')
+
+ await userEvent.click(modal.getByText('Back to proposals'))
+ })
+ })
+
+ await step('Abstain', async () => {
+ await step('Form', async () => {
+ await userEvent.click(screen.getByText('Vote on Proposal'))
+ expect(await modal.findByText('Vote for proposal'))
+ await userEvent.click(getButton(/^Abstain/i))
+ await fillRationale(modal)
+ await userEvent.click(getButton(/^sign transaction and vote/i))
+ })
+
+ await step('Sign', async () => {
+ expect(modal.getByText('Authorize transaction'))
+ const signText = modal.getByText(RegExp(`^You intend to .+ "${PROPOSAL_DATA.title}"\\.$`))
+ expect(within(signText).getByText('Abstain'))
+ await userEvent.click(modal.getByText(/^Sign transaction and Vote/))
+ })
+
+ await step('Confirm', async () => {
+ const confirmText = await modal.findByText(
+ RegExp(`^You have just successfully .+ \\W${PROPOSAL_DATA.title}\\W\\.$`)
+ )
+ expect(within(confirmText).getByText('Abstain'))
+
+ expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Abstain', 'Some rationale')
+ })
+ })
+ },
+}
+
+export const TestVoteInsufficientFunds: Story = {
+ args: { type: 'SignalProposalDetails', isCouncilMember: true },
+ parameters: { totalBalance: 1 },
+
+ name: 'Test VoteForProposalModal Insufficient Funds',
+
+ play: async ({ canvasElement }) => {
+ await userEvent.click(within(canvasElement).getByText('Vote on Proposal'))
+ expect(await withinModal(canvasElement).findByText('Insufficient Funds'))
+ },
+}
+
+export const TestVoteTxFailure: Story = {
+ args: { type: 'SignalProposalDetails', isCouncilMember: true },
+ parameters: { txFailure: 'Some error message' },
+
+ name: 'Test VoteForProposalModal Transaction Failure',
+
+ play: async ({ canvasElement }) => {
+ const screen = within(canvasElement)
+ const modal = withinModal(canvasElement)
+ const getButton = (text: string | RegExp) => getButtonByText(modal, text)
+
+ await userEvent.click(screen.getByText('Vote on Proposal'))
+
+ expect(await modal.findByText('Vote for proposal'))
+ await userEvent.click(getButton(/^Approve/i))
+ await fillRationale(modal)
+ await userEvent.click(getButton(/^sign transaction and vote/i))
+
+ await userEvent.click(modal.getByText(/^Sign transaction and Vote/))
+
+ expect(await modal.findByText('Failure'))
+ expect(await modal.findByText('Some error message'))
+ },
+}
diff --git a/packages/ui/src/app/pages/Bounty/components/BountiesHeader.stories.tsx b/packages/ui/src/bounty/components/BountiesHeader.stories.tsx
similarity index 80%
rename from packages/ui/src/app/pages/Bounty/components/BountiesHeader.stories.tsx
rename to packages/ui/src/bounty/components/BountiesHeader.stories.tsx
index afe1bb6acb..66de59abbc 100644
--- a/packages/ui/src/app/pages/Bounty/components/BountiesHeader.stories.tsx
+++ b/packages/ui/src/bounty/components/BountiesHeader.stories.tsx
@@ -1,7 +1,7 @@
import { Meta, Story } from '@storybook/react'
import React from 'react'
-import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvider'
+import { MockApolloProvider } from '../../mocks/components/storybook/MockApolloProvider'
import { BountiesHeader } from './BountiesHeader'
diff --git a/packages/ui/src/bounty/components/BountiesHeader.tsx b/packages/ui/src/bounty/components/BountiesHeader.tsx
new file mode 100644
index 0000000000..f22f96b2c7
--- /dev/null
+++ b/packages/ui/src/bounty/components/BountiesHeader.tsx
@@ -0,0 +1,10 @@
+import React from 'react'
+
+import { PageHeader } from '../../app/components/PageHeader'
+import { BountiesTabs } from '../../app/pages/Bounty/components/BountiesTabs'
+
+import { AddBountyButton } from './modalsButtons/AddBountyButton'
+
+export const BountiesHeader = () => {
+ return } buttons={} />
+}
diff --git a/packages/ui/src/common/components/CKEditor/CKEditor.tsx b/packages/ui/src/common/components/CKEditor/CKEditor.tsx
index dfac06bbd0..5cb7ce198d 100644
--- a/packages/ui/src/common/components/CKEditor/CKEditor.tsx
+++ b/packages/ui/src/common/components/CKEditor/CKEditor.tsx
@@ -20,7 +20,7 @@ export interface BaseCKEditorProps {
export const BaseCKEditor = React.forwardRef(
(
- { maxRows = 20, minRows = 5, onChange, onBlur, onFocus, onReady, disabled, inline }: CKEditorProps,
+ { id, maxRows = 20, minRows = 5, onChange, onBlur, onFocus, onReady, disabled, inline }: BaseCKEditorProps,
ref?: Ref
) => {
const localRef = useRef(null)
@@ -77,7 +77,16 @@ export const BaseCKEditor = React.forwardRef(
// This value must be kept in sync with the language defined in webpack.config.js.
language: 'en',
})
- .then((editor: any) => {
+ .then((editor: Editor) => {
+ // The component might be unmounted by the time it's initialize
+ // In this case the editor will be cleaned up when the promise resolves
+ if (!elementRef.current) return editor
+
+ Object.defineProperty(elementRef.current, 'setData', {
+ configurable: true,
+ value: (data: any) => editor.setData(data),
+ })
+
if (onReady) {
onReady(editor)
}
@@ -111,12 +120,12 @@ export const BaseCKEditor = React.forwardRef(
return () => {
createPromise.then((editor) => editor.destroy())
}
- }, [elementRef.current])
+ }, [])
return (
<>
-
+
>
)
}
diff --git a/packages/ui/src/common/components/forms/InputNumber.tsx b/packages/ui/src/common/components/forms/InputNumber.tsx
index 1d15175de4..0959ea2501 100644
--- a/packages/ui/src/common/components/forms/InputNumber.tsx
+++ b/packages/ui/src/common/components/forms/InputNumber.tsx
@@ -4,6 +4,8 @@ import { useFormContext, Controller } from 'react-hook-form'
import NumberFormat, { NumberFormatValues, SourceInfo } from 'react-number-format'
import styled from 'styled-components'
+import { asBN, whenDefined } from '@/common/utils'
+
import { Input, InputProps } from './InputComponent'
interface BaseNumberInputProps extends Omit {
@@ -56,16 +58,14 @@ export const InputNumber = React.memo(({ name, isInBN = false, ...props }: Numbe
{
- return (
- field.onChange(isInBN ? new BN(String(value)) : value)}
- onBlur={field.onBlur}
- />
- )
- }}
+ render={({ field }) => (
+ field.onChange(isInBN ? new BN(String(value)) : value)}
+ onBlur={field.onBlur}
+ />
+ )}
/>
)
})
diff --git a/packages/ui/src/common/components/forms/ToggleCheckbox.tsx b/packages/ui/src/common/components/forms/ToggleCheckbox.tsx
index 39e4ef77d8..954c275680 100644
--- a/packages/ui/src/common/components/forms/ToggleCheckbox.tsx
+++ b/packages/ui/src/common/components/forms/ToggleCheckbox.tsx
@@ -9,6 +9,7 @@ import { BorderRad, Colors, Fonts, Transitions } from '../../constants'
import { Label } from './Label'
export interface Props {
+ id?: string
isRequired?: boolean
disabled?: boolean
checked?: boolean
@@ -20,6 +21,7 @@ export interface Props {
}
function BaseToggleCheckbox({
+ id,
isRequired,
disabled,
checked,
@@ -40,6 +42,7 @@ function BaseToggleCheckbox({
{trueLabel}
console.warn(message, ...optionalParams)
diff --git a/packages/ui/src/common/modals/OnBoardingModal/OnBoardingModal.stories.tsx b/packages/ui/src/common/modals/OnBoardingModal/OnBoardingModal.stories.tsx
index 2d37ae18c1..377179fc70 100644
--- a/packages/ui/src/common/modals/OnBoardingModal/OnBoardingModal.stories.tsx
+++ b/packages/ui/src/common/modals/OnBoardingModal/OnBoardingModal.stories.tsx
@@ -17,7 +17,7 @@ import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvi
import { mockDefaultBalance } from '../../../../test/setup'
export default {
- title: 'App/OnBoardingModal',
+ title: 'App/Modals/OnBoardingModal',
component: OnBoardingModal,
} as Meta
diff --git a/packages/ui/src/common/types/helpers.ts b/packages/ui/src/common/types/helpers.ts
index 9691d0129b..fbb7b14131 100644
--- a/packages/ui/src/common/types/helpers.ts
+++ b/packages/ui/src/common/types/helpers.ts
@@ -11,7 +11,9 @@ export type Awaited = T extends PromiseLike ? U : T
export type RecursivePartial = {
[P in keyof T]?: T[P] extends (infer U)[]
? RecursivePartial[]
- : T[P] extends object | undefined
- ? RecursivePartial
- : T[P]
+ : T[P] extends infer U // This line distributes union types
+ ? U extends object
+ ? RecursivePartial
+ : U
+ : never
}
diff --git a/packages/ui/src/common/utils/validation.tsx b/packages/ui/src/common/utils/validation.tsx
index 8e6e338b3e..21abd6b935 100644
--- a/packages/ui/src/common/utils/validation.tsx
+++ b/packages/ui/src/common/utils/validation.tsx
@@ -1,7 +1,7 @@
import { isBn } from '@polkadot/util'
import BN from 'bn.js'
-import { at, get } from 'lodash'
-import React, { useCallback } from 'react'
+import { at, get, merge } from 'lodash'
+import React, { useCallback, useRef } from 'react'
import { FieldErrors, FieldValues, Resolver } from 'react-hook-form'
import { FieldError } from 'react-hook-form/dist/types/errors'
import { DeepMap, DeepPartial } from 'react-hook-form/dist/types/utils'
@@ -188,24 +188,27 @@ interface IFormError {
export const useYupValidationResolver = (
validationSchema: AnyObjectSchema,
path?: string
-): Resolver =>
- useCallback(
+): Resolver => {
+ const validationsPromise = useRef>(Promise.resolve())
+
+ return useCallback(
async (data, context) => {
let values
+
+ // Deep clone data since it's "by reference" attributes values might change by the time it runs
+ const _data = merge({}, data)
+ const options = {
+ abortEarly: false,
+ context,
+ stripUnknown: true,
+ }
+ const validate = () =>
+ path ? validationSchema.validateSyncAt(path, _data, options) : validationSchema.validateSync(_data, options)
+
+ validationsPromise.current = validationsPromise.current.then(validate, validate)
+
try {
- if (path) {
- values = await validationSchema.validateSyncAt(path, data, {
- abortEarly: false,
- context,
- stripUnknown: true,
- })
- } else {
- values = await validationSchema.validateSync(data, {
- abortEarly: false,
- context,
- stripUnknown: true,
- })
- }
+ values = await validationsPromise.current
return {
values,
@@ -220,6 +223,7 @@ export const useYupValidationResolver = (
},
[validationSchema, path]
)
+}
export interface ValidationHelpers {
errorMessageGetter: (field: string) => string | undefined
diff --git a/packages/ui/src/mocks/data/members.ts b/packages/ui/src/mocks/data/members.ts
index 1cf2171c0d..1d2fdb0704 100644
--- a/packages/ui/src/mocks/data/members.ts
+++ b/packages/ui/src/mocks/data/members.ts
@@ -1,11 +1,12 @@
-import { MemberFieldsFragment } from '@/memberships/queries'
+import { MemberWithDetailsFieldsFragment } from '@/memberships/queries'
import rawMembers from './raw/members.json'
-export type Membership = Omit
+export type Membership = Omit
export const member = (handle: string, { roles = [], ...extra }: Partial = {}) =>
({
...rawMembers.find((member) => member.handle === handle),
+ invitees: [],
...extra,
roles,
} as Membership)
diff --git a/packages/ui/src/mocks/data/proposals.ts b/packages/ui/src/mocks/data/proposals.ts
index b67ab62359..cee1a07020 100644
--- a/packages/ui/src/mocks/data/proposals.ts
+++ b/packages/ui/src/mocks/data/proposals.ts
@@ -1,12 +1,17 @@
+import { SubmittableExtrinsic } from '@polkadot/api/types'
import { random } from 'faker'
-import { mapValues } from 'lodash'
+import { mapValues, merge } from 'lodash'
import { RecursivePartial } from '@/common/types/helpers'
+import { repeat } from '@/common/utils'
import { worker, workingGroup, workingGroupOpening } from '@/mocks/data/common'
-import { joy } from '@/mocks/helpers'
+import { isoDate, joy } from '@/mocks/helpers'
import { ProposalWithDetailsFieldsFragment } from '@/proposals/queries'
-import { member } from './members'
+import { ProposalDetailsType, proposalDetailsToConstantKey } from '../helpers/proposalDetailsToConstantKey'
+import { MocksParameters } from '../providers'
+
+import { Membership, member } from './members'
import forumPosts from './raw/forumPosts.json'
import { randomMarkdown } from '@/../dev/query-node-mocks/generators/utils'
@@ -15,14 +20,20 @@ export type ProposalStatus = ProposalWithDetailsFieldsFragment['status']['__type
export const proposalActiveStatus = ['ProposalStatusDeciding', 'ProposalStatusDormant', 'ProposalStatusGracing']
+export type PartialProposal = RecursivePartial
+
const membership = member('eve')
-export const proposalDiscussionPosts = forumPosts.slice(0, 2).map(({ threadId, postAddedEvent, ...fields }) => ({
- ...fields,
- discussionThread: threadId,
- author: membership,
- status: { __typename: 'ProposalDiscussionPostStatusActive' },
- createdInEvent: postAddedEvent,
-}))
+
+type ProposalPost = ProposalWithDetailsFieldsFragment['discussionThread']['posts'][number]
+export const proposalDiscussionPosts: RecursivePartial[] = forumPosts
+ .slice(0, 2)
+ .map>(({ threadId, postAddedEvent, ...fields }) => ({
+ ...fields,
+ author: membership,
+ status: { __typename: 'ProposalDiscussionPostStatusActive' },
+ createdInEvent: postAddedEvent as Partial,
+ discussionThread: { id: threadId } as Partial,
+ }))
const proposalDetails = {
AmendConstitutionProposalDetails: {},
@@ -70,6 +81,205 @@ const proposalDetails = {
export const proposalDetailsMap = mapValues(
proposalDetails,
- (value, __typename) =>
- Object.assign(value, { __typename }) as RecursivePartial
+ (value, __typename) => Object.assign(value, { __typename }) as Partial
)
+
+export const proposalTypes = Object.keys(proposalDetailsMap) as ProposalDetailsType[]
+
+export const MAX_ACTIVE_PROPOSAL = 20
+
+type ProposalData = Omit & {
+ id: string
+ title: string
+ description: string
+ creator: Membership
+ type: ProposalDetailsType
+ status: ProposalStatus
+}
+export const generateProposal = (data: ProposalData): PartialProposal => ({
+ createdInEvent: { inBlock: 123, createdAt: isoDate('2023/01/02') },
+ statusSetAtBlock: 123,
+ statusSetAtTime: isoDate('2023/01/12'),
+
+ ...data,
+
+ details: proposalDetailsMap[data.type],
+ status: { __typename: data.status },
+})
+
+type ProposalsProps = {
+ title: string
+ description: string
+ creator: Membership
+ statuses: ProposalStatus[]
+ limit?: number
+ offset?: number
+}
+export const generateProposals = (
+ { title, description, creator, statuses, limit = 5, offset = 0 }: ProposalsProps,
+ max: number
+) =>
+ repeat((index) => {
+ const id = 123 + index + offset
+ return generateProposal({
+ id: String(id),
+ status: statuses.at(id % statuses.length) ?? statuses[0],
+ type: proposalTypes.at(id % proposalTypes.length) ?? proposalTypes[0],
+ title,
+ creator,
+ description,
+ councilApprovals: 0,
+ })
+ }, Math.min(limit, max - offset))
+
+type ProposalChainProps = {
+ activeProposalCount: number
+ minimumValidatorCount?: number
+ setMaxValidatorCountProposalMaxValidators?: number
+ initialInvitationCount?: number
+ initialInvitationBalance?: string
+
+ councilSize?: number
+ councilBudget?: string
+ councilorReward?: string
+ nextRewardPayments?: number
+
+ onAddStakingAccountCandidate?: jest.Mock
+ onConfirmStakingAccount?: jest.Mock
+ onCreateProposal?: jest.Mock
+ onChangeThreadMode?: jest.Mock
+
+ addStakingAccountCandidateFailure?: string
+ confirmStakingAccountFailure?: string
+ createProposalFailure?: string
+ changeThreadModeFailure?: string
+}
+type Chain = MocksParameters['chain']
+export const proposalsPagesChain = (
+ {
+ activeProposalCount,
+ minimumValidatorCount = 4,
+ setMaxValidatorCountProposalMaxValidators = 100,
+ initialInvitationCount = 5,
+ initialInvitationBalance = joy(5),
+
+ councilSize = 3,
+ councilBudget = joy(2000),
+ councilorReward = joy(200),
+ nextRewardPayments = 12345,
+
+ onAddStakingAccountCandidate,
+ onConfirmStakingAccount,
+ onCreateProposal,
+ onChangeThreadMode,
+
+ addStakingAccountCandidateFailure,
+ confirmStakingAccountFailure,
+ createProposalFailure,
+ changeThreadModeFailure,
+ }: ProposalChainProps,
+ extra?: Chain
+): Chain =>
+ merge(
+ {
+ consts: {
+ content: {
+ minimumCashoutAllowedLimit: joy(166),
+ maximumCashoutAllowedLimit: joy(1_666_666),
+ },
+
+ council: { councilSize, idlePeriodDuration: 1, announcingPeriodDuration: 1 },
+ referendum: { voteStageDuration: 1, revealStageDuration: 1 },
+
+ members: {
+ referralCutMaximumPercent: 50,
+ },
+
+ proposalsEngine: {
+ maxActiveProposalLimit: MAX_ACTIVE_PROPOSAL,
+ descriptionMaxLength: 3000,
+ titleMaxLength: 40,
+ },
+
+ proposalsCodex: {
+ fundingRequestProposalMaxTotalAmount: joy(166_666),
+ setMaxValidatorCountProposalMaxValidators,
+
+ ...Object.fromEntries(
+ proposalTypes.map((type) => [
+ proposalDetailsToConstantKey(type),
+ {
+ votingPeriod: 200,
+ gracePeriod: 100,
+ approvalQuorumPercentage: 80,
+ approvalThresholdPercentage: 100,
+ slashingQuorumPercentage: 60,
+ slashingThresholdPercentage: 80,
+ requiredStake: joy(20),
+ constitutionality: 2,
+ },
+ ])
+ ),
+ },
+ },
+
+ query: {
+ council: {
+ budget: councilBudget,
+ councilorReward,
+ nextRewardPayments,
+ stage: { stage: { isIdle: true }, changedAt: 123 },
+ },
+ referendum: { stage: {} },
+
+ members: {
+ initialInvitationCount,
+ initialInvitationBalance,
+ membershipPrice: joy(20),
+ stakingAccountIdMemberStatus: {
+ memberId: 0,
+ confirmed: false,
+ size: 0,
+ },
+ },
+
+ proposalsEngine: { activeProposalCount },
+ staking: { minimumValidatorCount },
+ },
+
+ tx: {
+ proposalsCodex: {
+ createProposal: { event: 'ProposalCreated', onSend: onCreateProposal, failure: createProposalFailure },
+ },
+ proposalsDiscussion: {
+ changeThreadMode: {
+ event: 'ThreadModeChanged',
+ onSend: onChangeThreadMode,
+ failure: changeThreadModeFailure,
+ },
+ },
+
+ members: {
+ addStakingAccountCandidate: {
+ event: 'StakingAccountAdded',
+ onSend: onAddStakingAccountCandidate,
+ failure: addStakingAccountCandidateFailure,
+ },
+ confirmStakingAccount: {
+ event: 'StakingAccountConfirmed',
+ onSend: onConfirmStakingAccount,
+ failure: confirmStakingAccountFailure,
+ },
+ },
+
+ utility: {
+ batch: {
+ failure: createProposalFailure,
+ onSend: (transactions: SubmittableExtrinsic<'rxjs'>[]) =>
+ transactions.forEach((transaction) => transaction.signAndSend('')),
+ },
+ },
+ },
+ } satisfies Chain,
+ extra
+ )
diff --git a/packages/ui/src/mocks/helpers/asChainData.ts b/packages/ui/src/mocks/helpers/asChainData.ts
index da92610f0e..590ac4c9e9 100644
--- a/packages/ui/src/mocks/helpers/asChainData.ts
+++ b/packages/ui/src/mocks/helpers/asChainData.ts
@@ -1,9 +1,11 @@
+import { createType } from '@joystream/types'
import { mapValues } from 'lodash'
-import { asBN } from '@/common/utils'
+import { isDefined } from '@/common/utils'
export const asChainData = (data: any): any => {
- switch (Object.getPrototypeOf(data).constructor.name) {
+ const type = isDefined(data) ? Object.getPrototypeOf(data).constructor.name : typeof data
+ switch (type) {
case 'Object':
return mapValues(data, asChainData)
@@ -11,10 +13,10 @@ export const asChainData = (data: any): any => {
return data.map(asChainData)
case 'Number':
- return asBN(data)
+ return createType('u128', data)
case 'String':
- return isNaN(data) ? data : asBN(data)
+ return isNaN(data) ? data : createType('u128', data)
default:
return data
diff --git a/packages/ui/src/mocks/helpers/index.ts b/packages/ui/src/mocks/helpers/index.ts
index 0c2ab25e19..7cda8debec 100644
--- a/packages/ui/src/mocks/helpers/index.ts
+++ b/packages/ui/src/mocks/helpers/index.ts
@@ -1,5 +1,9 @@
+import { isObject } from 'lodash'
+
import { JOY_DECIMAL_PLACES } from '@/common/constants'
+import { Balance } from '../providers/accounts'
+
export * from './storybook'
export { getMember } from '@/../test/_mocks/members'
@@ -7,7 +11,9 @@ export function camelCaseToDash(myStr: string) {
return myStr.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
}
-export const joy = (value: string | number): string => {
+export const joy = (value: Balance): string => {
+ if (isObject(value)) return value.toString()
+
const [integer = '0', decimal = ''] = value.toString().replace(/[,_ ]/g, '').split('.')
return `${integer}${decimal.padEnd(JOY_DECIMAL_PLACES, '0')}`
}
diff --git a/packages/ui/src/mocks/helpers/storybook.ts b/packages/ui/src/mocks/helpers/storybook.ts
index 07fdd9a83b..087094b92c 100644
--- a/packages/ui/src/mocks/helpers/storybook.ts
+++ b/packages/ui/src/mocks/helpers/storybook.ts
@@ -1,3 +1,59 @@
-import { merge as _merge } from 'lodash'
+import { expect } from '@storybook/jest'
+import { userEvent, waitFor, within } from '@storybook/testing-library'
+import { waitForOptions as WaitForOptions } from '@testing-library/dom/types'
+import * as queries from '@testing-library/dom/types/queries'
+import { BoundFunctions, SelectorMatcherOptions } from '@testing-library/react'
+import { merge } from 'lodash'
-export const merge = (...objs: T[]) => _merge({}, ...objs)
+export type Container = BoundFunctions
+
+export const withinModal = (canvasElement: HTMLElement): Container =>
+ within(canvasElement.querySelector('#modal-container') as HTMLElement)
+
+const mergeMatcherOptions = (
+ a: SelectorMatcherOptions | undefined,
+ b: SelectorMatcherOptions
+): SelectorMatcherOptions => {
+ if (!a) return b
+ else if (!a.selector || !b.selector) return merge({}, a, b)
+ else return merge({}, a, b, { selector: `:is(${a.selector}):is(${b.selector})` })
+}
+
+export const getButtonByText = (container: Container, text: string | RegExp, options?: SelectorMatcherOptions) =>
+ container.getByText(text, mergeMatcherOptions(options, { selector: 'span' })).parentElement as HTMLElement
+
+export const getEditorByLabel = async (
+ container: Container,
+ text: string | RegExp,
+ waitForOptions?: WaitForOptions
+) => {
+ const errMsg = (msg: string) => `Found a label with the text of: ${text}, however ${msg}`
+
+ const label = container.getByText(text, { selector: 'label[for]' })
+ const id = label.getAttribute('for')
+ if (!id) throw errMsg('this label for attribute is empty.')
+
+ const editor = document.getElementById(id)
+ if (!editor) throw errMsg('no element is associated with this label.')
+ await waitFor(() => {
+ if (!('setData' in editor)) throw 'Wait for the editor to be ready'
+ }, waitForOptions)
+
+ return editor as HTMLElement & { setData: (data: string) => void }
+}
+
+export const selectFromDropdown = async (container: Container, label: string | RegExp | HTMLElement, name: string) => {
+ const labelElement = label instanceof HTMLElement ? label : container.getByText(label)
+ const toggle = labelElement.parentElement?.querySelector('.ui-toggle')
+ if (!toggle) throw `Found a label: ${label.toString()}, however no dropdown is associated with this label.`
+
+ await userEvent.click(toggle)
+
+ const optionsWrapper = await waitFor(() => {
+ const optionsWrapper = document.getElementById('select-popper-wrapper')
+ expect(optionsWrapper).not.toBeNull()
+ return optionsWrapper as HTMLElement
+ }, {})
+
+ await userEvent.click(within(optionsWrapper).getByText(name))
+}
diff --git a/packages/ui/src/mocks/helpers/transactions.ts b/packages/ui/src/mocks/helpers/transactions.ts
new file mode 100644
index 0000000000..71df12902e
--- /dev/null
+++ b/packages/ui/src/mocks/helpers/transactions.ts
@@ -0,0 +1,90 @@
+import { createType } from '@joystream/types'
+import BN from 'bn.js'
+import { asyncScheduler, from, of, scheduled } from 'rxjs'
+
+import { whenDefined } from '@/common/utils'
+
+import { Balance } from '../providers/accounts'
+import { BLOCK_HASH } from '../providers/api'
+
+import { joy } from '.'
+import { asChainData } from './asChainData'
+
+export type TxMock = {
+ data?: any[] | any
+ failure?: string
+ event?: string
+ fee?: Balance
+ onCall?: CallableFunction
+ onSend?: CallableFunction
+}
+
+export const fromTxMock = (
+ { data, failure, event: eventName = 'Default Event', fee = 5, onCall, onSend }: TxMock,
+ moduleName: string
+) => {
+ const eventData = whenDefined(data, (data) => [asChainData(data)].flat()) ?? []
+ const event = failure ? createErrorEvents(failure) : createSuccessEvents(eventData, moduleName, eventName)
+ const txResult = stubTransactionResult(event)
+
+ const paymentInfo = () => of({ partialFee: createType('BalanceOf', joy(fee)) })
+
+ return (...args: any[]) => {
+ onCall?.(...args)
+ return {
+ paymentInfo,
+ signAndSend: () => {
+ onSend?.(...args)
+ return txResult
+ },
+ }
+ }
+}
+
+export const stubTransactionResult = (events: any[]) =>
+ scheduled(
+ from([
+ {
+ status: { isReady: true, type: 'Ready' },
+ },
+ {
+ status: { type: 'InBlock', isInBlock: true, asInBlock: BLOCK_HASH },
+ events: [...events],
+ },
+ {
+ status: { type: 'Finalized', isFinalized: true, asFinalized: BLOCK_HASH },
+ events: [...events],
+ },
+ ]),
+ asyncScheduler
+ )
+
+export const createSuccessEvents = (data: any[], section: string, method: string) => [
+ {
+ phase: { ApplyExtrinsic: 2 },
+ event: { index: '0x0502', data, method, section },
+ },
+ {
+ phase: { ApplyExtrinsic: 2 },
+ event: { index: '0x0000', data: [{ weight: 190949000, class: 'Normal', paysFee: 'Yes' }] },
+ },
+]
+
+export const createErrorEvents = (errorMessage: string) => [
+ {
+ phase: { ApplyExtrinsic: 2 },
+ event: {
+ index: '0x0001',
+ data: [
+ {
+ Module: { index: new BN(5), error: new BN(3) },
+ isModule: true,
+ registry: { findMetaError: () => ({ docs: [errorMessage] }) },
+ },
+ { weight: 190949000, class: 'Normal', paysFee: 'Yes' },
+ ],
+ section: 'system',
+ method: 'ExtrinsicFailed',
+ },
+ },
+]
diff --git a/packages/ui/src/mocks/providers/accounts.tsx b/packages/ui/src/mocks/providers/accounts.tsx
index 845262d79b..2e2bb9857f 100644
--- a/packages/ui/src/mocks/providers/accounts.tsx
+++ b/packages/ui/src/mocks/providers/accounts.tsx
@@ -1,4 +1,6 @@
+import { createType } from '@joystream/types'
import BN from 'bn.js'
+import { PolkadotLogo, Wallet } from 'injectweb3-connect'
import { isObject, isString, mapValues } from 'lodash'
import React, { FC, useCallback, useEffect, useState } from 'react'
@@ -6,7 +8,7 @@ import { AccountsContext } from '@/accounts/providers/accounts/context'
import { UseAccounts } from '@/accounts/providers/accounts/provider'
import { BalancesContext } from '@/accounts/providers/balances/context'
import { Account, AddressToBalanceMap, LockType } from '@/accounts/types'
-import { asBN, whenDefined } from '@/common/utils'
+import { whenDefined } from '@/common/utils'
import { MembershipContext } from '@/memberships/providers/membership/context'
import { MyMemberships } from '@/memberships/providers/membership/provider'
import { Member, asMember } from '@/memberships/types'
@@ -14,7 +16,7 @@ import { Member, asMember } from '@/memberships/types'
import { Membership } from '../data/members'
import { joy } from '../helpers'
-type Balance = number | string | BN
+export type Balance = number | string | BN
type BalanceLock = LockType | { amount: Balance; type: LockType }
type DeriveBalancesVesting = {
@@ -24,26 +26,30 @@ type DeriveBalancesVesting = {
locked: Balance
vested: Balance
}
-type Balances = {
- total?: Balance
- locked?: Balance
- recoverable?: Balance
- transferable?: Balance
- locks?: BalanceLock[]
- vesting?: DeriveBalancesVesting[]
- vestingTotal?: Balance
- vestedClaimable?: Balance
- vestedBalance?: Balance
- vestingLocked?: Balance
-}
+type Balances =
+ | Balance
+ | {
+ total?: Balance
+ locked?: Balance
+ recoverable?: Balance
+ transferable?: Balance
+ locks?: BalanceLock[]
+ vesting?: DeriveBalancesVesting[]
+ vestingTotal?: Balance
+ vestedClaimable?: Balance
+ vestedBalance?: Balance
+ vestingLocked?: Balance
+ }
type AccountMock = {
balances?: Balances
- address?: string
+ account?: { name: string; address: string }
member?: Membership
}
-type MockAccounts = { active?: Membership | Membership['id']; list?: AccountMock[] } | undefined
+type Active = AccountMock | Membership['handle']
+
+type MockAccounts = { active?: Active; list?: AccountMock[]; hasWallet?: boolean } | undefined
export type MockAccountsProps = { accounts?: MockAccounts }
@@ -54,38 +60,39 @@ export const MockAccountsProvider: FC = ({ children, accounts
const [active, setActive] = useState()
useEffect(() => {
- const list = accounts?.list ?? (accounts && isObject(accounts.active) ? [{ member: accounts.active }] : undefined)
+ const list = accounts?.list ?? (accounts && isObject(accounts.active) ? [accounts.active] : undefined)
if (!list) return
const accountData = list.flatMap(
- ({ balances, member, address = member?.controllerAccount }) =>
- whenDefined(address, (address) => ({ address, balances, member })) ?? []
+ ({ balances, member, account: { name = member?.handle, address = member?.controllerAccount } = {} }) =>
+ whenDefined(address, (address) => ({ name, address, balances, member })) ?? []
)
- const allAccounts: Account[] = accountData.map(({ address, member }) => ({ address, name: member?.handle }))
+ const allAccounts: Account[] = accountData.map(({ name, address }) => ({ name, address }))
const balances: AddressToBalanceMap = Object.fromEntries(
- accountData.map(({ address, balances }) => {
+ accountData.map(({ address, balances = 100 }) => {
+ const _balances = isObject(balances) && !(balances instanceof BN) ? balances : { total: balances }
const locks =
- balances?.locks?.map((lock) =>
+ _balances?.locks?.map((lock) =>
isString(lock) ? { amount: asBalance(1), type: lock } : { amount: asBalance(lock.amount), type: lock.type }
) ?? []
- const vesting = balances?.vesting?.map((schedule) => mapValues(schedule, asBalance)) ?? []
+ const vesting = _balances?.vesting?.map((schedule) => mapValues(schedule, asBalance)) ?? []
return [
address,
{
- total: asBalance(balances?.total),
- locked: asBalance(balances?.locked),
- recoverable: asBalance(balances?.recoverable),
- transferable: asBalance(balances?.transferable),
+ total: asBalance(_balances.total ?? _balances.transferable),
+ locked: asBalance(_balances.locked),
+ recoverable: asBalance(_balances.recoverable),
+ transferable: asBalance(_balances.transferable ?? _balances.total),
locks,
vesting,
- vestingTotal: asBalance(balances?.vestingTotal),
- vestedClaimable: asBalance(balances?.vestedClaimable),
- vestedBalance: asBalance(balances?.vestedBalance),
- vestingLocked: asBalance(balances?.vestingLocked),
+ vestingTotal: asBalance(_balances.vestingTotal),
+ vestedClaimable: asBalance(_balances.vestedClaimable),
+ vestedBalance: asBalance(_balances.vestedBalance),
+ vestingLocked: asBalance(_balances.vestingLocked),
},
]
})
@@ -94,7 +101,7 @@ export const MockAccountsProvider: FC = ({ children, accounts
const members = accountData.flatMap(({ member }) => whenDefined(member, asMember) ?? [])
const active = whenDefined(accounts?.active, (active) =>
- isObject(active) ? asMember(active) : members.find(({ handle }) => handle === active)
+ isString(active) ? members.find(({ handle }) => handle === active) : active.member && asMember(active.member)
)
setAllAccounts(allAccounts)
@@ -115,6 +122,7 @@ export const MockAccountsProvider: FC = ({ children, accounts
allAccounts,
hasAccounts: true,
isLoading: false,
+ wallet: accounts.hasWallet === false ? undefined : WALLET,
}
const membershipContextValue: MyMemberships = {
@@ -135,4 +143,21 @@ export const MockAccountsProvider: FC = ({ children, accounts
)
}
-const asBalance = (balance: Balance = 0): BN => (balance instanceof BN ? balance : asBN(joy(balance)))
+const asBalance = (balance: Balance = 0): BN =>
+ (balance instanceof BN ? balance : createType('BalanceOf', joy(balance))) as BN
+
+const WALLET: Wallet = {
+ installed: true,
+ enable: () => undefined,
+ extensionName: 'foo',
+ title: 'bar',
+ installUrl: 'http://example.com',
+ logo: { src: PolkadotLogo, alt: 'Wallet logo' },
+ signer: {},
+ extension: {},
+ getAccounts: async () => [],
+ subscribeAccounts: () => undefined,
+ updateMetadata: async () => false,
+ walletAccountToInjectedAccountWithMeta: () => ({ address: '0x123', meta: { source: '' } }),
+ transformError: () => Error(),
+}
diff --git a/packages/ui/src/mocks/providers/api.tsx b/packages/ui/src/mocks/providers/api.tsx
index 1daa553492..39cc1b3f43 100644
--- a/packages/ui/src/mocks/providers/api.tsx
+++ b/packages/ui/src/mocks/providers/api.tsx
@@ -1,21 +1,32 @@
-import { isFunction, set } from 'lodash'
+import { AugmentedConsts, AugmentedQueries, AugmentedSubmittables } from '@polkadot/api/types'
+import { RpcInterface } from '@polkadot/rpc-core/types'
+import { Codec } from '@polkadot/types/types'
+import { isFunction, isObject, mapValues, merge } from 'lodash'
import React, { FC, useEffect, useMemo, useState } from 'react'
import { Observable, of } from 'rxjs'
import { Api } from '@/api'
import { ApiContext } from '@/api/providers/context'
import { UseApi } from '@/api/providers/provider'
+import { warning } from '@/common/logger'
import { createType } from '@/common/model/createType'
-import { joy } from '../helpers'
import { asChainData } from '../helpers/asChainData'
+import { TxMock, fromTxMock } from '../helpers/transactions'
+
+export const BLOCK_HEAD = 1337
+export const BLOCK_HASH = '0x1234567890'
+
+type RecursiveMock, R, V = any> = {
+ [K in keyof T]?: T[K] extends R ? V : RecursiveMock
+}
type MockApi = {
- consts?: Record
- derive?: Record
- query?: Record
- rpc?: Record
- tx?: Record
+ consts?: RecursiveMock, Codec>
+ derive?: RecursiveMock
+ query?: RecursiveMock, CallableFunction>
+ rpc?: RecursiveMock
+ tx?: RecursiveMock, CallableFunction, TxMock>
}
export type MockApiProps = { chain?: MockApi }
@@ -28,47 +39,40 @@ export const MockApiProvider: FC = ({ children, chain }) => {
const api = useMemo(() => {
if (!chain) return
- // Add default mocks
- const blockHash = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY'
- const blockHead = {
- parentHash: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
- number: 1337,
- stateRoot: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
- extrinsicsRoot: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY',
- digest: { logs: [] },
+ // Common mocks:
+ const rpcChain = {
+ getBlockHash: createType('BlockHash', BLOCK_HASH),
+ subscribeNewHeads: {
+ parentHash: BLOCK_HASH,
+ number: BLOCK_HEAD,
+ stateRoot: BLOCK_HASH,
+ extrinsicsRoot: BLOCK_HASH,
+ digest: { logs: [] },
+ },
}
const api = {
- _async: { chainMetadata: Promise.resolve({}) },
+ _async: { chainMetadata: Promise.resolve({}) } as Api['_async'],
isConnected: true,
- consts: {},
- derive: {},
- query: {},
- rpc: {
- chain: {
- getBlockHash: asApiMethod(createType('BlockHash', blockHash)),
- subscribeNewHeads: asApiMethod(createType('Header', blockHead)),
- },
- },
- tx: {},
- } as Api
-
- // Add mocks from parameters
- traverseParams('consts', (path, value) => set(api, path, asApiConst(value)))
- traverseParams('derive', (path, value) => set(api, path, asApiMethod(value)))
- traverseParams('query', (path, value) => set(api, path, asApiMethod(value)))
- traverseParams('rpc', (path, value) => set(api, path, asApiMethod(value)))
- traverseParams('tx', (path, { paymentInfo, signAndSend }) => {
- set(api.tx, `${path}.paymentInfo`, asApiMethod(paymentInfo ?? joy(5)))
- set(api.tx, `${path}.signAndSend`, asApiMethod(signAndSend ?? undefined))
- })
-
- return api
-
- function traverseParams(kind: keyof MockApi, fn: (path: string, value: any) => any) {
- Object.entries(chain?.[kind] ?? {}).forEach(([moduleName, moduleParam]) =>
- Object.entries(moduleParam).forEach(([key, value]) => fn(`${kind}.${moduleName}.${key}`, value))
- )
+ consts: asApi('consts', asApiConst),
+ derive: asApi('derive', asApiMethod),
+ query: asApi('query', asApiMethod),
+ rpc: asApi('rpc', asApiMethod, { chain: rpcChain }),
+ tx: asApi('tx', fromTxMock),
+ }
+
+ return watchForMissingProps(api, 'api') as Api
+
+ function asApi(
+ kind: K,
+ fn: (value: any, moduleName: string) => any,
+ common: MockApi[K] = {}
+ ) {
+ const chainData: MockApi[K] = merge(common, chain?.[kind])
+ return mapValues(chainData, (moduleData, moduleName) => {
+ const module = mapValues(moduleData, (value) => fn(value, moduleName))
+ return watchForMissingProps(module, `${kind}.${moduleName}`)
+ }) as Api[K]
}
}, [chain])
@@ -93,6 +97,15 @@ export const MockApiProvider: FC = ({ children, chain }) => {
return {children}
}
+const watchForMissingProps = >(target: T, path: string): T =>
+ new Proxy(target, {
+ get(target, p) {
+ const key = p as unknown as string
+ if (!(key in target)) warning('Missing chain data:', `${path}.${key}`)
+ return target[key]
+ },
+ })
+
const asApiConst = (value: any) => {
if (isFunction(value)) {
return value()
@@ -105,7 +118,13 @@ const asApiMethod = (value: any) => {
return value
} else if (value instanceof Observable) {
return () => value
- } else {
- return () => of(asChainData(value))
}
+
+ const method = () => of(asChainData(value))
+
+ if (isObject(value) && 'size' in value) {
+ method.size = () => of(asChainData(value.size))
+ }
+
+ return method
}
diff --git a/packages/ui/src/mocks/providers/index.tsx b/packages/ui/src/mocks/providers/index.tsx
index 481e47d269..8067b49eb4 100644
--- a/packages/ui/src/mocks/providers/index.tsx
+++ b/packages/ui/src/mocks/providers/index.tsx
@@ -5,11 +5,14 @@ import React, { useMemo } from 'react'
import { MockAccountsProps, MockAccountsProvider } from './accounts'
import { MockApiProvider, MockApiProps } from './api'
import { MockQNProps, MockQNProvider } from './query-node'
+import { MockLocalStorage, useMockLocalStorage } from './useMockLocalStorage'
-export type MocksParameters = MockApiProps & MockQNProps & MockAccountsProps
+export * from './router'
+
+export type MocksParameters = MockApiProps & MockQNProps & MockAccountsProps & MockLocalStorage
type Context = StoryContext & {
- parameters: { mocks: MocksParameters | ((storyContext: StoryContext) => MocksParameters) }
+ parameters: { mocks?: MocksParameters | ((storyContext: StoryContext) => MocksParameters) }
}
export const MockProvidersDecorator = (Story: CallableFunction, storyContext: Context) => {
@@ -18,6 +21,8 @@ export const MockProvidersDecorator = (Story: CallableFunction, storyContext: Co
return isFunction(mocks) ? mocks(storyContext) : mocks
}, [storyContext])
+ useMockLocalStorage(mocks?.localStorage)
+
return (
diff --git a/packages/ui/src/mocks/providers/query-node.tsx b/packages/ui/src/mocks/providers/query-node.tsx
index 8f817b964b..d0444c6bf9 100644
--- a/packages/ui/src/mocks/providers/query-node.tsx
+++ b/packages/ui/src/mocks/providers/query-node.tsx
@@ -1,18 +1,29 @@
import { DocumentNode } from '@apollo/client/core'
import React, { FC, createContext, useCallback, useContext, useMemo, useState } from 'react'
+import { warning } from '@/common/logger'
+
+import { BLOCK_HEAD } from './api'
+
export { ApolloClient, gql, HttpLink, InMemoryCache } from '@apollo/client/core'
export { ApolloProvider } from '@apollo/client/react'
-type Options = { variables: any; skip: boolean } | undefined
+type OptionVariables = { where?: Record<'string', any>; orderBy?: string | string[]; limit?: number; offset?: number }
+type Options = { variables?: OptionVariables; skip?: boolean }
type Result = { loading: boolean; data: any }
-type Resolver = (options: Options) => Result
+type Resolver = (options?: Options) => Result
type QueryMap = Map
const QNMockContext = createContext(new Map())
export const useQuery = (query: DocumentNode, options?: Options): Result => {
- const result = useContext(QNMockContext).get(query)?.(options) ?? { loading: false, data: undefined }
+ const qnMocks = useContext(QNMockContext)
+
+ if (!qnMocks.has(query)) {
+ warning('Missing mock query:', (query.definitions[0] as any).name.value ?? query.loc?.source.body)
+ }
+
+ const result = qnMocks.get(query)?.(options) ?? { loading: false, data: undefined }
return useMemo(() => result, [JSON.stringify(result)])
}
@@ -24,7 +35,7 @@ export const useLazyQuery = (query: DocumentNode, options?: Options): [() => voi
return [get, lazyResult]
}
-export const useSubscription = useQuery
+export const useSubscription = () => ({ data: { stateSubscription: { indexerHead: BLOCK_HEAD } } })
export const useApolloClient = () => ({
refetchQueries: () => undefined,
diff --git a/packages/ui/src/mocks/providers/router.tsx b/packages/ui/src/mocks/providers/router.tsx
new file mode 100644
index 0000000000..dd28627c3f
--- /dev/null
+++ b/packages/ui/src/mocks/providers/router.tsx
@@ -0,0 +1,42 @@
+import { Decorator } from '@storybook/react'
+import React from 'react'
+import { MemoryRouter, Redirect, Route, Switch } from 'react-router'
+
+import { NotFound } from '@/app/pages/NotFound'
+
+type MockRouterOptions = {
+ href?: string
+ path?: string
+ enable404?: boolean
+ actions?: Record any>
+}
+
+export const MockRouterDecorator: Decorator = (Story, { parameters }) => {
+ const options = (parameters.router ?? {}) as MockRouterOptions
+ const storyPath = options.href ?? '/'
+
+ return (
+ <>
+
+
+
+
+
+ {Object.entries(options.actions ?? {}).map(([path, action]) => (
+ {
+ action()
+ return null
+ }}
+ />
+ ))}
+
+ {options.enable404 && }
+
+
+
+ >
+ )
+}
diff --git a/packages/ui/src/mocks/providers/useMockLocalStorage.ts b/packages/ui/src/mocks/providers/useMockLocalStorage.ts
new file mode 100644
index 0000000000..93dffa6c66
--- /dev/null
+++ b/packages/ui/src/mocks/providers/useMockLocalStorage.ts
@@ -0,0 +1,23 @@
+import { useMemo } from 'react'
+
+export type MockLocalStorage = { localStorage?: Record }
+
+export const useMockLocalStorage = (state: Record = {}) => {
+ const storageMap = useMemo(() => new Map(Object.entries(state)), [state])
+
+ const storage: Storage = useMemo(
+ () => ({
+ get length() {
+ return storageMap.size
+ },
+ key: (index) => Array.from(storageMap.keys())[index],
+ clear: () => storageMap.clear(),
+ getItem: (key) => storageMap.get(key) ?? null,
+ setItem: (key, value) => storageMap.set(key, value),
+ removeItem: (key) => storageMap.delete(key),
+ }),
+ [storageMap]
+ )
+
+ Object.defineProperty(global, 'localStorage', { get: () => storage })
+}
diff --git a/packages/ui/src/proposals/components/ProposalFilters/ProposalFilters.stories.tsx b/packages/ui/src/proposals/components/ProposalFilters/ProposalFilters.stories.tsx
index 6b9bd12846..28e13eddfd 100644
--- a/packages/ui/src/proposals/components/ProposalFilters/ProposalFilters.stories.tsx
+++ b/packages/ui/src/proposals/components/ProposalFilters/ProposalFilters.stories.tsx
@@ -10,7 +10,7 @@ import { proposalStatuses } from '@/proposals/model/proposalStatus'
import { ProposalFilters, ProposalFiltersProps } from '.'
export default {
- title: 'Proposals/ProposalFilters',
+ title: 'Pages/Proposals/ProposalList/Past/Components/ProposalFilters',
component: ProposalFilters,
argTypes: {
onApply: { action: 'Apply' },
diff --git a/packages/ui/src/proposals/components/ProposalList/ProposalList.stories.tsx b/packages/ui/src/proposals/components/ProposalList/ProposalList.stories.tsx
index 14a0778993..d9cfe21db2 100644
--- a/packages/ui/src/proposals/components/ProposalList/ProposalList.stories.tsx
+++ b/packages/ui/src/proposals/components/ProposalList/ProposalList.stories.tsx
@@ -6,7 +6,7 @@ import { getMember } from '../../../../test/_mocks/members'
import { ProposalList, ProposalListProps } from '.'
export default {
- title: 'Proposals/ProposalList',
+ title: 'Pages/Proposals/ProposalList/Current/Components/ProposalList',
component: ProposalList,
} as Meta
diff --git a/packages/ui/src/proposals/components/ProposalList/ProposalListItem.stories.tsx b/packages/ui/src/proposals/components/ProposalList/ProposalListItem.stories.tsx
index d3c5c38c59..a4c130a8c5 100644
--- a/packages/ui/src/proposals/components/ProposalList/ProposalListItem.stories.tsx
+++ b/packages/ui/src/proposals/components/ProposalList/ProposalListItem.stories.tsx
@@ -6,7 +6,7 @@ import { getMember } from '../../../../test/_mocks/members'
import { ProposalListItem } from './ProposalListItem'
export default {
- title: 'Proposals/ProposalListItem',
+ title: 'Pages/Proposals/ProposalList/Current/Components/ProposalListItem',
component: ProposalListItem,
} as Meta
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx b/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx
index e083d9fbc1..113c6a43f6 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/AddNewProposalModal.tsx
@@ -64,7 +64,7 @@ export type BaseProposalParams = Exclude<
const minimalSteps = [{ title: 'Bind account for staking' }, { title: 'Create proposal' }]
export const AddNewProposalModal = () => {
- const { api, connectionState } = useApi()
+ const { api } = useApi()
const { active: activeMember } = useMyMemberships()
const minimumValidatorCount = useMinimumValidatorCount()
const maximumReferralCut = api?.consts.members.referralCutMaximumPercent
@@ -77,7 +77,7 @@ export const AddNewProposalModal = () => {
const [formMap, setFormMap] = useState>([])
const workingGroupConsts = api?.consts[formMap[2] as GroupIdName]
- const [warningAccepted, setWarningAccepted] = useState(true)
+ const [warningAccepted, setWarningAccepted] = useState(false)
const [isExecutionError, setIsExecutionError] = useState(false)
const constants = useProposalConstants(formMap[1])
@@ -86,7 +86,7 @@ export const AddNewProposalModal = () => {
const stakingStatus = useStakingAccountStatus(formMap[0]?.address, activeMember?.id, [state.matches('transaction')])
const schema = useMemo(() => schemaFactory(api), [!api])
- const path = useMemo(() => machineStateConverter(state.value), [state.value])
+ const path = useMemo(() => machineStateConverter(state.value) as keyof AddNewProposalForm, [state.value])
const form = useForm({
resolver: useYupValidationResolver(schema, path),
mode: 'onChange',
@@ -109,6 +109,11 @@ export const AddNewProposalModal = () => {
defaultValues: defaultProposalValues,
})
+ const formValues = form.getValues() as AddNewProposalForm
+ const currentErrors = form.formState.errors[path] ?? {}
+ const serializedCurrentForm = JSON.stringify(formValues[path])
+ const serializedCurrentFormErrors = JSON.stringify(currentErrors)
+
const mapDependencies = form.watch([
'stakingAccount.stakingAccount',
'proposalType.type',
@@ -133,15 +138,12 @@ export const AddNewProposalModal = () => {
useEffect(() => {
form.trigger([])
+ setWarningAccepted(false)
}, [path])
useEffect(() => {
- setIsExecutionError(
- Object.values((form.formState.errors as any)[path] ?? {}).some(
- (value) => (value as FieldError).type === 'execution'
- )
- )
- }, [JSON.stringify(form.formState.errors), path])
+ setIsExecutionError(Object.values(currentErrors).some((value) => (value as FieldError).type === 'execution'))
+ }, [serializedCurrentFormErrors])
const transactionsSteps = useMemo(
() =>
@@ -153,8 +155,7 @@ export const AddNewProposalModal = () => {
activeMember?.controllerAccount,
async () => {
if (activeMember && api) {
- const { proposalDetails, triggerAndDiscussion, stakingAccount, ...specifics } =
- form.getValues() as AddNewProposalForm
+ const { proposalDetails, triggerAndDiscussion, stakingAccount, ...specifics } = formValues
const txBaseParams: BaseProposalParams = {
memberId: activeMember?.id,
@@ -176,13 +177,7 @@ export const AddNewProposalModal = () => {
])
}
},
- [
- state.value,
- connectionState,
- stakingStatus,
- form.formState.isValidating,
- JSON.stringify(form.getValues()?.[path as keyof AddNewProposalForm]),
- ]
+ [api?.isConnected, activeMember, stakingStatus, serializedCurrentForm]
)
useEffect((): any => {
@@ -211,8 +206,6 @@ export const AddNewProposalModal = () => {
}
}, [state, stakingStatus, feeInfo])
- useEffect(() => setWarningAccepted(!isExecutionError), [isExecutionError])
-
const goToPrevious = useCallback(() => {
send('BACK')
setIsExecutionError(false)
@@ -223,14 +216,11 @@ export const AddNewProposalModal = () => {
return true
}
if (isExecutionError) {
- const hasOtherError = Object.values((form.formState.errors as any)[path] ?? {}).some(
- (value) => (value as FieldError).type !== 'execution'
- )
-
if (!form.formState.isDirty) {
return true
}
+ const hasOtherError = Object.values(currentErrors).some((value) => (value as FieldError).type !== 'execution')
if (!hasOtherError) {
return !warningAccepted
}
@@ -239,15 +229,7 @@ export const AddNewProposalModal = () => {
}
return !form.formState.isValid
- }, [
- form.formState.isValid,
- form.formState.isDirty,
- isExecutionError,
- warningAccepted,
- JSON.stringify(form.getValues()),
- JSON.stringify(form.formState.errors),
- isLoading,
- ])
+ }, [form.formState.isValid, form.formState.isDirty, isExecutionError, warningAccepted, isLoading])
if (!api || !activeMember || !feeInfo || state.matches('requirementsVerification')) {
return null
@@ -309,7 +291,7 @@ export const AddNewProposalModal = () => {
}
if (state.matches('discussionTransaction')) {
- const { triggerAndDiscussion } = form.getValues() as AddNewProposalForm
+ const { triggerAndDiscussion } = formValues
const threadMode = createType('PalletProposalsDiscussionThreadModeBTreeSet', {
closed: triggerAndDiscussion.discussionWhitelist?.map((member) =>
createType('MemberId', Number.parseInt(member.id))
@@ -336,7 +318,7 @@ export const AddNewProposalModal = () => {
}
if (state.matches('success')) {
- const { proposalDetails, proposalType } = form.getValues() as AddNewProposalForm
+ const { proposalDetails, proposalType } = formValues
return (
{
Specific parameters
- Set Council Budget Increment
+ Tokens added to council budget every day
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/SetMembershipLeadInvitationQuota.stories.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/SetMembershipLeadInvitationQuota.stories.tsx
index a41d534d8b..a616f84e9a 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/SetMembershipLeadInvitationQuota.stories.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/SetMembershipLeadInvitationQuota.stories.tsx
@@ -5,7 +5,7 @@ import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvi
import { SetMembershipLeadInvitationQuota } from '@/proposals/modals/AddNewProposal/components/SpecificParameters/SetMembershipLeadInvitationQuota'
export default {
- title: 'Proposals/AddNewProposalModal/SetMembershipLeadInvitationQuota',
+ title: 'Pages/Proposals/ProposalList/Current/Modals/AddNewProposalModal/SetMembershipLeadInvitationQuota',
component: SetMembershipLeadInvitationQuota,
} as Meta
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/SetReferralCut.stories.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/SetReferralCut.stories.tsx
index b2942651ce..54f9dd3f34 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/SetReferralCut.stories.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/SetReferralCut.stories.tsx
@@ -5,7 +5,7 @@ import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvi
import { SetReferralCut } from '@/proposals/modals/AddNewProposal/components/SpecificParameters/SetReferralCut'
export default {
- title: 'Proposals/AddNewProposalModal/SetReferralCut',
+ title: 'Pages/Proposals/ProposalList/Current/Modals/AddNewProposalModal/SetReferralCut',
component: SetReferralCut,
} as Meta
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/UpdateChannelPayouts/UpdateChannelPayouts.stories.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/UpdateChannelPayouts/UpdateChannelPayouts.stories.tsx
index bbe2fffda6..726268ae09 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/UpdateChannelPayouts/UpdateChannelPayouts.stories.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/UpdateChannelPayouts/UpdateChannelPayouts.stories.tsx
@@ -6,7 +6,7 @@ import { AddNewProposalTemplate } from '@/proposals/components/StorybookTemplate
import { UpdateChannelPayouts } from './UpdateChannelPayouts'
export default {
- title: 'Proposals/AddNewProposalModal/UpdateChannelPayouts',
+ title: 'Pages/Proposals/ProposalList/Current/Modals/AddNewProposalModal/UpdateChannelPayouts',
component: UpdateChannelPayouts,
} as Meta
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CancelWorkingGroupLeadOpening.stories.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CancelWorkingGroupLeadOpening.stories.tsx
index d2e3942fcd..07a1fec3fd 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CancelWorkingGroupLeadOpening.stories.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CancelWorkingGroupLeadOpening.stories.tsx
@@ -5,7 +5,7 @@ import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvi
import { CancelWorkingGroupLeadOpening } from '@/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CancelWorkingGroupLeadOpening'
export default {
- title: 'Proposals/AddNewProposalModal/CancelWorkingGroupLeadOpening',
+ title: 'Pages/Proposals/ProposalList/Current/Modals/AddNewProposalModal/CancelWorkingGroupLeadOpening',
component: CancelWorkingGroupLeadOpening,
} as Meta
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/CreateWorkingGroupLeadOpening.stories.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/CreateWorkingGroupLeadOpening.stories.tsx
index 69ee0e2181..1d9a39a126 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/CreateWorkingGroupLeadOpening.stories.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/CreateWorkingGroupLeadOpening/CreateWorkingGroupLeadOpening.stories.tsx
@@ -6,7 +6,7 @@ import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvi
import { ApplicationForm, DurationAndProcess, StakingPolicyAndReward, WorkingGroupAndDescription } from '.'
export default {
- title: 'Proposals/AddNewProposalModal/CreateWorkingGroupLeadOpening',
+ title: 'Pages/Proposals/ProposalList/Current/Modals/AddNewProposalModal/CreateWorkingGroupLeadOpening',
components: [ApplicationForm, DurationAndProcess, StakingPolicyAndReward, WorkingGroupAndDescription],
} as Meta
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/FillWorkingGroupLeadOpening.stories.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/FillWorkingGroupLeadOpening.stories.tsx
index 9f42979036..5e778e7730 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/FillWorkingGroupLeadOpening.stories.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/FillWorkingGroupLeadOpening.stories.tsx
@@ -5,7 +5,7 @@ import { MockApolloProvider } from '@/mocks/components/storybook/MockApolloProvi
import { FillWorkingGroupLeadOpening } from '@/proposals/modals/AddNewProposal/components/SpecificParameters/WorkingGroupLeadOpening/FillWorkingGroupLeadOpening'
export default {
- title: 'Proposals/AddNewProposalModal/FillWorkingGroupLeadOpening',
+ title: 'Pages/Proposals/ProposalList/Current/Modals/AddNewProposalModal/FillWorkingGroupLeadOpening',
component: FillWorkingGroupLeadOpening,
} as Meta
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/SuccessModal.stories.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/SuccessModal.stories.tsx
index d19f7d94ae..8b17abe462 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/components/SuccessModal.stories.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/SuccessModal.stories.tsx
@@ -5,7 +5,7 @@ import { info } from '@/common/logger'
import { SuccessModal } from '@/proposals/modals/AddNewProposal/components/SuccessModal'
export default {
- title: 'Proposals/AddNewProposalModal/SuccessModal',
+ title: 'Pages/Proposals/ProposalList/Current/Modals/AddNewProposalModal/SuccessModal',
component: SuccessModal,
} as Meta
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/components/WarningModal.tsx b/packages/ui/src/proposals/modals/AddNewProposal/components/WarningModal.tsx
index d1bb41ef4e..a07b8e6029 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/components/WarningModal.tsx
+++ b/packages/ui/src/proposals/modals/AddNewProposal/components/WarningModal.tsx
@@ -40,9 +40,9 @@ export const WarningModal = ({ onNext }: AddNewProposalWarningModalProps) => {
- Iām aware of the possible risks associated with creating a proposal.
+ I'm aware of the possible risks associated with creating a proposal.
-
+
Do not show this message again.
diff --git a/packages/ui/src/proposals/modals/AddNewProposal/helpers.ts b/packages/ui/src/proposals/modals/AddNewProposal/helpers.ts
index 41ee89adb5..4b9e16d647 100644
--- a/packages/ui/src/proposals/modals/AddNewProposal/helpers.ts
+++ b/packages/ui/src/proposals/modals/AddNewProposal/helpers.ts
@@ -321,7 +321,7 @@ export const schemaFactory = (api?: Api) => {
.required('Field is required'),
}),
setMembershipLeadInvitationQuota: Yup.object().shape({
- count: BNSchema.test(moreThanMixed(0, 'Quota must be greater than zero')).required('Field is required'),
+ count: Yup.number().min(1, 'Quota must be greater than zero').required('Field is required'),
leadId: Yup.string().test('execution', (value) => !!value),
}),
setInitialInvitationBalance: Yup.object().shape({
diff --git a/packages/ui/src/proposals/modals/VoteForProposal/VoteForProposalModalForm.tsx b/packages/ui/src/proposals/modals/VoteForProposal/VoteForProposalModalForm.tsx
index 06ba0fd3b1..804571c0b8 100644
--- a/packages/ui/src/proposals/modals/VoteForProposal/VoteForProposalModalForm.tsx
+++ b/packages/ui/src/proposals/modals/VoteForProposal/VoteForProposalModalForm.tsx
@@ -91,8 +91,9 @@ export const VoteForProposalModalForm = ({ proposal, send, context }: Props) =>
{isRejected && (
-
+
/src/app/pages/*/*.stories.tsx'],
+ testMatch: ['/src/app/**/*.stories.tsx'],
}
diff --git a/packages/ui/test/_mocks/transactions.ts b/packages/ui/test/_mocks/transactions.ts
index e2f58d02b6..3588abe900 100644
--- a/packages/ui/test/_mocks/transactions.ts
+++ b/packages/ui/test/_mocks/transactions.ts
@@ -3,7 +3,7 @@ import { AugmentedEvents } from '@polkadot/api/types'
import { AnyTuple } from '@polkadot/types/types'
import BN from 'bn.js'
import { set } from 'lodash'
-import { asyncScheduler, from, Observable, of, scheduled } from 'rxjs'
+import { from, Observable, of } from 'rxjs'
import { toBalances } from '@/accounts/model/toBalances'
import { UseAccounts } from '@/accounts/providers/accounts/provider'
@@ -13,60 +13,14 @@ import { UseApi } from '@/api/providers/provider'
import { BN_ZERO } from '@/common/constants'
import { createType } from '@/common/model/createType'
import { ExtractTuple } from '@/common/model/JoystreamNode'
+import { createErrorEvents, createSuccessEvents, stubTransactionResult } from '@/mocks/helpers/transactions'
import { proposalDetails } from '@/proposals/model/proposalDetails'
import { mockedBalances, mockedMyBalances, mockedUseMyAccounts } from '../setup'
import { createBalanceLock, createRuntimeDispatchInfo } from './chainTypes'
-const createSuccessEvents = (data: any[], section: string, method: string) => [
- {
- phase: { ApplyExtrinsic: 2 },
- event: { index: '0x0502', data, method, section },
- },
- {
- phase: { ApplyExtrinsic: 2 },
- event: { index: '0x0000', data: [{ weight: 190949000, class: 'Normal', paysFee: 'Yes' }] },
- },
-]
-
export const currentStubErrorMessage = 'Balance too low to send value.'
-const findMetaError = () => ({
- docs: [currentStubErrorMessage],
-})
-
-const createErrorEvents = () => [
- {
- phase: { ApplyExtrinsic: 2 },
- event: {
- index: '0x0001',
- data: [
- { Module: { index: new BN(5), error: new BN(3) }, isModule: true, registry: { findMetaError } },
- { weight: 190949000, class: 'Normal', paysFee: 'Yes' },
- ],
- section: 'system',
- method: 'ExtrinsicFailed',
- },
- },
-]
-
-export const stubTransactionResult = (events: any[]) =>
- scheduled(
- from([
- {
- status: { isReady: true, type: 'Ready' },
- },
- {
- status: { type: 'InBlock', isInBlock: true, asInBlock: '0x93XXX' },
- events: [...events],
- },
- {
- status: { type: 'Finalized', isFinalized: true, asFinalized: '0x93XXX' },
- events: [...events],
- },
- ]),
- asyncScheduler
- )
const createBatchSuccessEvents = () => [
{
@@ -96,7 +50,7 @@ const createBatchErrorEvents = () => [
]
export const stubTransactionFailure = (transaction: any) => {
- set(transaction, 'signAndSend', () => stubTransactionResult(createErrorEvents()))
+ set(transaction, 'signAndSend', () => stubTransactionResult(createErrorEvents(currentStubErrorMessage)))
}
type PartialTuple = Partial
diff --git a/packages/ui/test/app/OnBoardingOverlay.test.tsx b/packages/ui/test/app/OnBoardingOverlay.test.tsx
deleted file mode 100644
index 594bed3f88..0000000000
--- a/packages/ui/test/app/OnBoardingOverlay.test.tsx
+++ /dev/null
@@ -1,89 +0,0 @@
-import { cleanup, render, screen } from '@testing-library/react'
-import { Wallet } from 'injectweb3-connect'
-import React from 'react'
-
-import { OnBoardingOverlay, onBoardingSteps } from '@/app/components/OnboardingOverlay/OnBoardingOverlay'
-import { UseOnBoarding } from '@/common/providers/onboarding/types'
-
-import { mockedUseMyAccounts } from '../setup'
-
-const mockOnBoarding: UseOnBoarding = {
- status: 'installPlugin',
- isLoading: false,
- setMembershipAccount: jest.fn(),
-}
-
-jest.mock('@/common/hooks/useOnBoarding', () => ({
- useOnBoarding: () => mockOnBoarding,
-}))
-
-jest.mock('@/api/hooks/useApi', () => ({
- useApi: () => ({
- api: {
- isConnected: true,
- },
- }),
-}))
-
-describe('OnBoardingOverlay', () => {
- afterEach(cleanup)
-
- it('Loading', () => {
- mockOnBoarding.isLoading = true
-
- const { queryByText } = renderComponent()
-
- expect(queryByText('Join Now')).toBeNull()
- })
-
- describe('Loaded', () => {
- beforeAll(() => {
- mockOnBoarding.isLoading = false
- })
-
- it('No wallet', () => {
- renderComponent()
-
- expect(screen.queryByText('Connect Wallet')).toBeInTheDocument()
- })
-
- it('After wallet is selected', () => {
- mockedUseMyAccounts.mockReturnValue({
- allAccounts: [],
- hasAccounts: false,
- isLoading: true,
- wallet: {} as Wallet,
- })
- renderComponent()
-
- expect(screen.queryByText('Connect Wallet')).not.toBeInTheDocument()
- expect(screen.queryByText('Join Now')).toBeInTheDocument()
- })
-
- it('Expands', () => {
- const { getByText } = renderComponent()
-
- getByText(/^Show how$/i).click()
-
- expect(getByText('What are the benefits?')).toBeDefined()
- expect(getByText('How to become a member?')).toBeDefined()
- })
-
- it('Renders all steps', () => {
- renderComponent()
-
- onBoardingSteps.map(({ title }) => {
- expect(screen.queryByText(title)).toBeInTheDocument()
- })
- })
-
- it('Finished', () => {
- mockOnBoarding.status = 'finished'
- const { queryByText } = renderComponent()
-
- expect(queryByText('Join Now')).toBeNull()
- })
- })
-
- const renderComponent = () => render()
-})
diff --git a/packages/ui/test/bounty/modals/AnnounceWorkEntryModal.test.tsx b/packages/ui/test/bounty/modals/AnnounceWorkEntryModal.test.tsx
index db27aa69a7..c63ce1dc29 100644
--- a/packages/ui/test/bounty/modals/AnnounceWorkEntryModal.test.tsx
+++ b/packages/ui/test/bounty/modals/AnnounceWorkEntryModal.test.tsx
@@ -2,7 +2,7 @@ import { fireEvent, render, RenderResult, screen } from '@testing-library/react'
import BN from 'bn.js'
import React from 'react'
-import { MoveFundsModalCall } from '@/accounts/modals/MoveFoundsModal'
+import { MoveFundsModalCall } from '@/accounts/modals/MoveFundsModal'
import { ApiContext } from '@/api/providers/context'
import { AnnounceWorkEntryModal } from '@/bounty/modals/AnnounceWorkEntryModal'
import { formatTokenValue } from '@/common/model/formatters'
diff --git a/packages/ui/test/common/modals/OnBoardingModal.test.tsx b/packages/ui/test/common/modals/OnBoardingModal.test.tsx
deleted file mode 100644
index eb36e2cd84..0000000000
--- a/packages/ui/test/common/modals/OnBoardingModal.test.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import BN from 'bn.js'
-import React from 'react'
-import { act } from 'react-dom/test-utils'
-import { MemoryRouter } from 'react-router'
-
-import { ApiContext } from '@/api/providers/context'
-import { Colors } from '@/common/constants'
-import { OnBoardingModal } from '@/common/modals/OnBoardingModal'
-import { ModalContext } from '@/common/providers/modal/context'
-import { UseModal } from '@/common/providers/modal/types'
-import { UseOnBoarding } from '@/common/providers/onboarding/types'
-
-import { MockApolloProvider } from '../../_mocks/providers'
-import { stubAccounts, stubApi } from '../../_mocks/transactions'
-import { mockedMyBalances, zeroBalance } from '../../setup'
-
-const mockOnBoarding: UseOnBoarding = {
- status: 'installPlugin',
- isLoading: false,
- setMembershipAccount: jest.fn(),
-}
-
-jest.mock('@/common/hooks/useOnBoarding', () => ({
- useOnBoarding: () => mockOnBoarding,
-}))
-const mockWallets = [
- {
- installUrl: 'extrawallet.com',
- title: 'ExtraWallet',
- logo: { alt: 'alt', src: '' },
- installed: false,
- extensionName: 'name',
- },
-]
-jest.mock('injectweb3-connect', () => ({
- getAllWallets: () => mockWallets,
- getWalletBySource: () => undefined,
-}))
-
-describe('UI: OnBoardingModal', () => {
- const api = stubApi()
-
- const useModal: UseModal = {
- hideModal: jest.fn(),
- showModal: jest.fn(),
- modal: null,
- modalData: undefined,
- }
-
- afterEach(cleanup)
-
- it('Do not render', () => {
- mockOnBoarding.isLoading = true
-
- const { queryByText } = renderModal()
-
- expect(queryByText('Add Polkadot plugin')).toBeNull()
- })
-
- describe('Status: Install plugin', () => {
- beforeAll(() => {
- mockOnBoarding.isLoading = false
- mockOnBoarding.status = 'installPlugin'
- })
-
- it('Stepper matches', () => {
- const { getByText } = renderModal()
-
- const pluginStepCircle = getStepperStepCircle('Connect wallet', getByText)
- expect(pluginStepCircle).toHaveStyle(`background-color: ${Colors.Blue[500]}`)
- })
-
- it('Opens website', () => {
- const windowSpy = jest.spyOn(window, 'open').mockImplementation(jest.fn())
- const { getByText } = renderModal()
- const extension = screen.getByText(mockWallets[0].title)
- fireEvent.click(extension)
-
- const pluginButton = getByText('Install extension')
- expect(pluginButton).toBeDefined()
- expect(pluginButton).toBeEnabled()
-
- act(() => pluginButton.click())
-
- expect(windowSpy).toBeCalledWith(mockWallets[0].installUrl, '_blank')
- })
- })
-
- describe('Status: addAccount', () => {
- beforeAll(() => {
- mockOnBoarding.status = 'addAccount'
- })
-
- it('Stepper matches', () => {
- const { getByText } = renderModal()
-
- const accountCircle = getStepperStepCircle('Connect account', getByText)
-
- expect(accountCircle).toHaveStyle(`background-color: ${Colors.Blue[500]}`)
- })
-
- describe('Add account step', () => {
- it('Create account instructions', () => {
- const { queryByText } = renderModal()
-
- expect(queryByText('Create an account')).toBeDefined()
- })
- })
-
- describe('Pick account step', () => {
- beforeAll(() => {
- stubAccounts([
- {
- address: '123',
- name: 'Alice',
- },
- {
- address: '321',
- name: 'Bob',
- },
- ])
- mockedMyBalances.mockReturnValue({
- '123': {
- ...zeroBalance,
- total: new BN(10),
- },
- '321': {
- ...zeroBalance,
- },
- })
- })
-
- it('Shows correct screen', () => {
- const { queryByText } = renderModal()
-
- expect(queryByText('Connect accounts')).toBeDefined()
- })
-
- it('Shows accounts with balance', () => {
- const { getByText } = renderModal()
-
- const alice = getByText('Alice')
- const bob = getByText('Bob')
-
- expect(alice).toBeDefined()
- expect(bob).toBeDefined()
-
- const aliceBalance = alice?.parentElement?.parentElement?.children?.item(1)?.textContent
- const bobBalance = bob?.parentElement?.parentElement?.children?.item(1)?.textContent
-
- expect(aliceBalance).toBe('10')
- expect(bobBalance).toBe('0')
- })
-
- it('Proceed to next step', () => {
- const { getByText } = renderModal()
-
- act(() => getByText('Alice').click())
- act(() => getByText('Connect Account').click())
-
- expect(mockOnBoarding.setMembershipAccount).toBeCalledWith('123')
- })
- })
- })
-
- describe('Status: createMembership', () => {
- beforeAll(() => {
- mockOnBoarding.status = 'createMembership'
- })
-
- it('Step matches', () => {
- const { getByText } = renderModal()
-
- const membershipCircle = getStepperStepCircle('Create free membership', getByText)
-
- expect(membershipCircle).toHaveStyle(`background-color: ${Colors.Blue[500]}`)
- })
-
- it('Shows correct screen', () => {
- const { queryByText } = renderModal()
-
- expect(queryByText(/Root account/i)).toBeDefined()
- expect(queryByText(/Create A Membership/)).toBeDefined()
- })
- })
-
- const getStepperStepCircle = (text: string, getByText: any) => getByText(text)?.parentElement?.previousElementSibling
-
- const renderModal = () =>
- render(
-
-
-
-
-
-
-
-
-
- )
-})
diff --git a/packages/ui/test/council/modals/AnnounceCandidacyModal.test.tsx b/packages/ui/test/council/modals/AnnounceCandidacyModal.test.tsx
index cfe2c2a1ed..d990ea1c85 100644
--- a/packages/ui/test/council/modals/AnnounceCandidacyModal.test.tsx
+++ b/packages/ui/test/council/modals/AnnounceCandidacyModal.test.tsx
@@ -262,7 +262,7 @@ describe('UI: Announce Candidacy Modal', () => {
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'
)
- expect(screen.queryByText(/^maximum length is \d+ symbols/i)).not.toBeNull()
+ await waitFor(() => expect(screen.getByText(/^maximum length is \d+ symbols/i)))
expect(await getNextStepButton()).toBeDisabled()
})
@@ -271,7 +271,7 @@ describe('UI: Announce Candidacy Modal', () => {
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua!Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'
)
- expect(screen.queryByText(/^maximum length is \d+ symbols/i)).not.toBeNull()
+ await waitFor(() => expect(screen.getByText(/^maximum length is \d+ symbols/i)))
expect(await getNextStepButton()).toBeDisabled()
})
diff --git a/packages/ui/test/membership/modals/BuyMembershipModal.test.tsx b/packages/ui/test/membership/modals/BuyMembershipModal.test.tsx
deleted file mode 100644
index be398ff4cd..0000000000
--- a/packages/ui/test/membership/modals/BuyMembershipModal.test.tsx
+++ /dev/null
@@ -1,202 +0,0 @@
-import { cryptoWaitReady } from '@polkadot/util-crypto'
-import { act, configure, fireEvent, render, screen } from '@testing-library/react'
-import BN from 'bn.js'
-import { set } from 'lodash'
-import React from 'react'
-import { MemoryRouter } from 'react-router'
-import { of } from 'rxjs'
-
-import { ApiContext } from '@/api/providers/context'
-import { GlobalModals } from '@/app/GlobalModals'
-import { createType } from '@/common/model/createType'
-import { ModalContextProvider } from '@/common/providers/modal/provider'
-import { BuyMembershipModal } from '@/memberships/modals/BuyMembershipModal'
-
-import { getButton } from '../../_helpers/getButton'
-import { selectFromDropdown } from '../../_helpers/selectFromDropdown'
-import { createBalanceOf } from '../../_mocks/chainTypes'
-import { alice, bob } from '../../_mocks/keyring'
-import { MockKeyringProvider, MockQueryNodeProviders } from '../../_mocks/providers'
-import { setupMockServer } from '../../_mocks/server'
-import {
- stubAccounts,
- stubApi,
- stubBalances,
- stubDefaultBalances,
- stubTransaction,
- stubTransactionFailure,
- stubTransactionSuccess,
-} from '../../_mocks/transactions'
-import { mockUseModalCall } from '../../setup'
-
-configure({ testIdAttribute: 'id' })
-
-describe('UI: BuyMembershipModal', () => {
- const api = stubApi()
- let transaction: any
- const showModal = jest.fn()
-
- setupMockServer()
-
- beforeAll(async () => {
- await cryptoWaitReady()
- mockUseModalCall({ showModal })
- jest.spyOn(console, 'log').mockImplementation()
- stubAccounts([alice, bob])
- })
-
- beforeEach(async () => {
- stubDefaultBalances()
- set(api, 'api.query.members.membershipPrice', () => of(createBalanceOf(100)))
- set(api, 'api.query.members.memberIdByHandleHash.size', () => of(new BN(0)))
- transaction = stubTransaction(api, 'api.tx.members.buyMembership')
- })
-
- it('Renders a modal', async () => {
- await renderModal()
-
- expect(await screen.findByText('Add membership')).toBeDefined()
- expect((await screen.findByText('Creation fee:'))?.parentNode?.textContent).toMatch(/^Creation fee:100/i)
- })
-
- it('Disables button on incorrect email address', async () => {
- await renderModal()
- const submitButton = await findSubmitButton()
- await fillForm()
- await act(() => {
- fireEvent.click(screen.getByTestId('email-tile'))
- fireEvent.change(screen.getByTestId('email-input'), { target: { value: 'invalid email' } })
- })
- expect(submitButton).toBeDisabled()
- })
-
- it('Enables button when valid form', async () => {
- await renderModal()
- const submitButton = await findSubmitButton()
-
- expect(submitButton).toBeDisabled()
- await fillForm()
- expect(submitButton).not.toBeDisabled()
- })
-
- // Skip because avatar is not mandatory ATM (this test shouldn't have been passing)
- it.skip('Disables button when invalid avatar URL', async () => {
- await renderModal()
- const submitButton = await findSubmitButton()
- expect(submitButton).toBeDisabled()
-
- await fillForm()
- await act(async () => {
- fireEvent.change(screen.getByLabelText(/member avatar/i), { target: { value: 'avatar' } })
- })
- expect(submitButton).toBeDisabled()
-
- await act(async () => {
- fireEvent.change(screen.getByLabelText(/member avatar/i), { target: { value: 'http://example.com/example.jpg' } })
- })
- expect(submitButton).not.toBeDisabled()
- })
-
- describe('Authorize step', () => {
- const renderAuthorizeStep = async () => {
- await renderModal()
- await fillForm()
-
- fireEvent.click(await findSubmitButton())
- }
-
- it('Renders authorize transaction', async () => {
- await renderAuthorizeStep()
-
- expect(screen.getByText('modals.authorizeTransaction.title')).toBeDefined()
- expect(screen.getByText(/^Creation fee:/i)?.nextSibling?.textContent).toBe('100')
- expect(screen.getByText(/^modals.transactionFee.label/i)?.nextSibling?.textContent).toBe('25')
- expect(screen.getByRole('heading', { name: /alice/i })).toBeDefined()
- })
-
- it('Without required balance', async () => {
- stubBalances({ available: 0, locked: 0 })
-
- await renderAuthorizeStep()
-
- expect(await getButton(/^sign and/i)).toBeDisabled()
- })
-
- describe('Success', () => {
- it('Renders transaction success', async () => {
- stubTransactionSuccess(transaction, 'members', 'MembershipBought', [createType('MemberId', 1)])
- await renderAuthorizeStep()
-
- await act(async () => {
- fireEvent.click(await getButton(/^sign and create a member$/i))
- })
-
- expect(await screen.findByText('Success')).toBeDefined()
- expect(screen.getByText(/^realbobbybob/i)).toBeDefined()
- })
-
- it('Enables the View My Profile button', async () => {
- stubTransactionSuccess(transaction, 'members', 'MembershipBought', [createType('MemberId', 12)])
- await renderAuthorizeStep()
-
- await act(async () => {
- fireEvent.click(await getButton(/^sign and create a member$/i))
- })
-
- expect(await screen.findByText('Success')).toBeDefined()
- const button = await getButton('View my profile')
- expect(button).toBeEnabled()
- await act(async () => {
- fireEvent.click(button)
- })
- expect(showModal.mock.calls[0][0]).toEqual({ modal: 'Member', data: { id: '12' } })
- })
- })
-
- describe('Failure', () => {
- it('Renders transaction failure', async () => {
- stubTransactionFailure(transaction)
- await renderAuthorizeStep()
-
- await act(async () => {
- fireEvent.click(await getButton(/^sign and create a member$/i))
- })
-
- expect(await screen.findByText('Failure')).toBeDefined()
- })
- })
- })
-
- async function fillForm() {
- await selectFromDropdown('Root account', 'bob')
- await selectFromDropdown('Controller account', 'alice')
- await act(async () => {
- fireEvent.change(screen.getByLabelText(/member name/i), { target: { value: 'BobbyBob' } })
- fireEvent.change(screen.getByLabelText(/Membership handle/i), { target: { value: 'realbobbybob' } })
- fireEvent.click(screen.getByLabelText(/I agree to the terms/i))
- })
- }
-
- async function findSubmitButton() {
- return await getButton(/^Create a membership$/i)
- }
-
- async function renderModal() {
- await act(async () => {
- render(
-
-
-
-
-
-
-
-
-
-
-
-
- )
- })
- }
-})
diff --git a/packages/ui/test/proposals/modals/AddNewProposalModal.test.tsx b/packages/ui/test/proposals/modals/AddNewProposalModal.test.tsx
index 4a277df82b..68729d7551 100644
--- a/packages/ui/test/proposals/modals/AddNewProposalModal.test.tsx
+++ b/packages/ui/test/proposals/modals/AddNewProposalModal.test.tsx
@@ -1,119 +1,6 @@
-import { OpeningMetadata } from '@joystream/metadata-protobuf'
-import { cryptoWaitReady } from '@polkadot/util-crypto'
-import { act, configure, fireEvent, render, screen, waitFor } from '@testing-library/react'
import BN from 'bn.js'
-import { findLast } from 'lodash'
-import React from 'react'
-import { MemoryRouter } from 'react-router'
-import { interpret } from 'xstate'
-import { MoveFundsModalCall } from '@/accounts/modals/MoveFundsModal'
-import { ApiContext } from '@/api/providers/context'
-import { CurrencyName } from '@/app/constants/currency'
-import { GlobalModals } from '@/app/GlobalModals'
-import { CKEditorProps } from '@/common/components/CKEditor'
-import { camelCaseToText } from '@/common/helpers'
import { createType } from '@/common/model/createType'
-import { metadataFromBytes } from '@/common/model/JoystreamNode/metadataFromBytes'
-import { getSteps } from '@/common/model/machines/getSteps'
-import { ModalContextProvider } from '@/common/providers/modal/provider'
-import { powerOf2 } from '@/common/utils/bn'
-import { MembershipContext } from '@/memberships/providers/membership/context'
-import { MyMemberships } from '@/memberships/providers/membership/provider'
-import {
- seedApplication,
- seedApplications,
- seedMembers,
- seedOpening,
- seedOpenings,
- seedOpeningStatuses,
- seedUpcomingOpenings,
- seedWorkers,
- seedWorkingGroups,
- updateWorkingGroups,
-} from '@/mocks/data'
-import workingGroups from '@/mocks/data/raw/workingGroups.json'
-import { addNewProposalMachine } from '@/proposals/modals/AddNewProposal/machine'
-import { ProposalType } from '@/proposals/types'
-
-import { getButton } from '../../_helpers/getButton'
-import { selectFromDropdown } from '../../_helpers/selectFromDropdown'
-import { toggleCheckBox } from '../../_helpers/toggleCheckBox'
-import { mockCKEditor } from '../../_mocks/components/CKEditor'
-import { mockUseCurrentBlockNumber } from '../../_mocks/hooks/useCurrentBlockNumber'
-import { alice, bob } from '../../_mocks/keyring'
-import { getMember } from '../../_mocks/members'
-import { MockKeyringProvider, MockQueryNodeProviders } from '../../_mocks/providers'
-import { setupMockServer } from '../../_mocks/server'
-import {
- stubAccounts,
- stubApi,
- stubConst,
- stubCouncilAndReferendum,
- stubCouncilConstants,
- stubDefaultBalances,
- stubProposalConstants,
- stubQuery,
- stubTransaction,
- stubTransactionFailure,
- stubTransactionSuccess,
-} from '../../_mocks/transactions'
-import { mockedTransactionFee, mockTransactionFee, mockUseModalCall } from '../../setup'
-
-const QUESTION_INPUT = OpeningMetadata.ApplicationFormQuestion.InputType
-
-configure({ testIdAttribute: 'id' })
-
-jest.mock('@/common/components/CKEditor', () => ({
- CKEditor: (props: CKEditorProps) => mockCKEditor(props),
-}))
-
-jest.mock('@/common/hooks/useCurrentBlockNumber', () => ({
- useCurrentBlockNumber: () => mockUseCurrentBlockNumber(),
-}))
-
-jest.mock('react-dropzone', () => {
- const reactDropzone = jest.requireActual('react-dropzone')
- return {
- ...reactDropzone,
- useDropzone: (props: any) =>
- reactDropzone.useDropzone({
- ...props,
- getFilesFromEvent: (event: React.ChangeEvent) => event.target.files,
- }),
- }
-})
-
-const OPENING_DATA = {
- id: 'forumWorkingGroup-1337',
- runtimeId: 1337,
- groupId: 'forumWorkingGroup',
- stakeAmount: 4000,
- rewardPerBlock: 200,
- version: 1,
- type: 'LEADER',
- status: 'open',
- unstakingPeriod: 25110,
- metadata: {
- title: 'Foo',
- shortDescription: '',
- description: '',
- hiringLimit: 1,
- applicationDetails: '',
- applicationFormQuestions: [],
- expectedEnding: '2021-12-06T14:26:06.283Z',
- },
-}
-
-const APPLICATION_DATA = {
- id: 'forumWorkingGroup-1337',
- runtimeId: 1337,
- openingId: 'forumWorkingGroup-1337',
- applicantId: '0',
- answers: [],
- status: 'pending',
- stake: new BN(10000),
-}
describe('AddNewProposalModal types parameters', () => {
describe('Specific parameters', () => {
@@ -137,1615 +24,3 @@ describe('AddNewProposalModal types parameters', () => {
})
})
})
-
-describe('UI: AddNewProposalModal', () => {
- const api = stubApi()
-
- const useMyMemberships: MyMemberships = {
- active: undefined,
- members: [],
- setActive: (member) => (useMyMemberships.active = member),
- isLoading: false,
- hasMembers: true,
- helpers: {
- getMemberIdByBoundAccountAddress: () => undefined,
- },
- }
- const forumLeadId = workingGroups.find((group) => group.id === 'forumWorkingGroup')?.leadId
- const showModal = jest.fn()
- let createProposalTx: any
- let batchTx: any
- let bindAccountTx: any
- let changeModeTx: any
- let createProposalTxMock: jest.Mock
-
- const server = setupMockServer({ noCleanupAfterEach: true })
-
- beforeAll(async () => {
- await cryptoWaitReady()
- mockUseModalCall({ showModal, modal: 'AddNewProposalModal' })
- seedMembers(server.server)
- seedWorkingGroups(server.server)
- seedOpeningStatuses(server.server)
- seedOpenings(server.server)
- seedUpcomingOpenings(server.server)
- seedApplications(server.server)
- seedOpening(OPENING_DATA, server.server)
- seedApplication(APPLICATION_DATA, server.server)
- seedWorkers(server.server)
- updateWorkingGroups(server.server)
- stubAccounts([alice, bob])
- })
-
- beforeEach(async () => {
- mockTransactionFee({ feeInfo: { transactionFee: new BN(100), canAfford: true } })
-
- useMyMemberships.members = [getMember('alice'), getMember('bob')]
- useMyMemberships.setActive(getMember('alice'))
-
- stubDefaultBalances()
- stubProposalConstants(api)
- stubCouncilConstants(api)
- stubCouncilAndReferendum(api, 'Announcing', 'Inactive')
-
- createProposalTx = stubTransaction(api, 'api.tx.proposalsCodex.createProposal', 25)
- createProposalTxMock = api.api.tx.proposalsCodex.createProposal as unknown as jest.Mock
-
- stubTransaction(api, 'api.tx.members.confirmStakingAccount', 25)
- stubQuery(
- api,
- 'members.stakingAccountIdMemberStatus',
- createType('PalletMembershipStakingAccountMemberBinding', {
- memberId: 0,
- confirmed: false,
- })
- )
- stubQuery(api, 'members.stakingAccountIdMemberStatus.size', createType('u64', 0))
- stubQuery(api, 'content.minCashoutAllowed', new BN(5))
- stubQuery(api, 'content.maxCashoutAllowed', new BN(5000))
- stubConst(api, 'content.minimumCashoutAllowedLimit', new BN(5))
- stubConst(api, 'content.maximumCashoutAllowedLimit', new BN(5000))
- stubConst(api, 'proposalsEngine.titleMaxLength', createType('u32', 1000))
- stubConst(api, 'proposalsEngine.descriptionMaxLength', createType('u32', 1000))
- batchTx = stubTransaction(api, 'api.tx.utility.batch')
- bindAccountTx = stubTransaction(api, 'api.tx.members.addStakingAccountCandidate', 42)
- changeModeTx = stubTransaction(api, 'api.tx.proposalsDiscussion.changeThreadMode', 10)
- })
-
- describe('Requirements', () => {
- beforeEach(async () => {
- await renderModal()
- })
-
- it('No active member', async () => {
- useMyMemberships.active = undefined
-
- await renderModal()
-
- expect(showModal).toBeCalledWith({
- modal: 'SwitchMember',
- data: { originalModalName: 'AddNewProposalModal', originalModalData: null },
- })
- })
- })
-
- describe('Warning modal', () => {
- beforeEach(async () => {
- await renderModal()
- })
- it('Not checked', async () => {
- const button = await getWarningNextButton()
- expect(await screen.queryByText('Do not show this message again.')).toBeDefined()
- expect(button).toBeDisabled()
- })
-
- it('Checked', async () => {
- const button = await getWarningNextButton()
-
- const checkbox = await getCheckbox()
- fireEvent.click(checkbox as HTMLElement)
-
- expect(button).toBeEnabled()
- })
- })
-
- describe('Stepper modal', () => {
- it('Renders a modal', async () => {
- await finishWarning()
-
- expect(await screen.findByText('Creating new proposal')).toBeDefined()
- })
-
- it('Steps', () => {
- const service = interpret(addNewProposalMachine)
- service.start()
-
- expect(getSteps(service)).toEqual([
- { title: 'Proposal type', type: 'next' },
- { title: 'General parameters', type: 'next' },
- { title: 'Staking account', type: 'next', isBaby: true },
- { title: 'Proposal details', type: 'next', isBaby: true },
- { title: 'Trigger & Discussion', type: 'next', isBaby: true },
- { title: 'Specific parameters', type: 'next' },
- ])
- })
-
- describe('Proposal type', () => {
- beforeEach(async () => {
- await finishWarning()
- })
-
- it('Not selected', async () => {
- const button = await getNextStepButton()
- expect(button).toBeDisabled()
- })
-
- it('Selected', async () => {
- const type = (await screen.findByText('Funding Request')).parentElement?.parentElement as HTMLElement
- fireEvent.click(type)
-
- const button = await getNextStepButton()
- expect(button).not.toBeDisabled()
- })
- })
-
- describe('Required stake', () => {
- beforeEach(async () => {
- await finishWarning()
- })
-
- it('Not enough funds', async () => {
- const requiredStake = 9999
- stubProposalConstants(api, { requiredStake })
- await finishProposalType()
-
- const moveFundsModalCall: MoveFundsModalCall = {
- modal: 'MoveFundsModal',
- data: {
- requiredStake: new BN(requiredStake),
- lock: 'Proposals',
- isFeeOriented: false,
- },
- }
-
- expect(showModal).toBeCalledWith({ ...moveFundsModalCall })
- })
-
- it('Enough funds', async () => {
- await finishProposalType()
- expect(screen.findByText('Creating new proposal')).toBeDefined()
- })
- })
-
- describe('General parameters', () => {
- describe('Staking account', () => {
- beforeEach(async () => {
- await finishWarning()
- await finishProposalType()
- })
-
- it('Not selected', async () => {
- const button = await getNextStepButton()
- expect(button).toBeDisabled()
- })
-
- it('Selected', async () => {
- await selectFromDropdown('Select account for Staking', 'alice')
-
- const button = await getNextStepButton()
- expect(button).not.toBeDisabled()
- })
- })
-
- describe('Proposal details', () => {
- beforeEach(async () => {
- await finishWarning()
- await finishProposalType()
- await finishStakingAccount()
- })
-
- it('Not filled', async () => {
- const button = await getNextStepButton()
- expect(button).toBeDisabled()
- })
-
- it('Filled', async () => {
- await fillProposalDetails()
-
- const button = await getNextStepButton()
- expect(button).not.toBeDisabled()
- })
- })
-
- describe('Proposal details validation', () => {
- beforeEach(async () => {
- stubConst(api, 'proposalsEngine.titleMaxLength', createType('u32', 5))
- stubConst(api, 'proposalsEngine.descriptionMaxLength', createType('u32', 5))
- await finishWarning()
- await finishProposalType()
- })
- it('Title too long', async () => {
- stubConst(api, 'proposalsEngine.titleMaxLength', createType('u32', 5))
-
- await finishStakingAccount()
-
- await fillProposalDetails()
-
- expect(await screen.findByText(/Title exceeds maximum length/i)).toBeDefined()
- const button = await getNextStepButton()
- expect(button).toBeDisabled()
- })
-
- it('Description too long', async () => {
- await finishStakingAccount()
-
- await fillProposalDetails()
-
- expect(await screen.findByText(/Rationale exceeds maximum length/i)).toBeDefined()
- const button = await getNextStepButton()
- expect(button).toBeDisabled()
- })
-
- it('Both fields too long', async () => {
- await finishStakingAccount()
-
- await fillProposalDetails()
-
- expect(await screen.findByText(/Title exceeds maximum length/i)).toBeDefined()
- expect(await screen.findByText(/Rationale exceeds maximum length/i)).toBeDefined()
- const button = await getNextStepButton()
- expect(button).toBeDisabled()
- })
- })
-
- describe('Trigger & Discussion', () => {
- beforeEach(async () => {
- await finishWarning()
- await finishProposalType()
- await finishStakingAccount()
- await finishProposalDetails()
- })
-
- it('Default(Trigger - No, Discussion Mode - Open)', async () => {
- const button = await getNextStepButton()
- expect(button).not.toBeDisabled()
- })
-
- describe('Trigger - Yes', () => {
- beforeEach(async () => {
- await triggerYes()
- })
-
- it('Not filled block number', async () => {
- const button = await getNextStepButton()
- expect(button).toBeDisabled()
- })
-
- it('Invalid block number: too low', async () => {
- await triggerYes()
- await fillTriggerBlock(10)
-
- await waitFor(async () => expect(await screen.getByText('The minimum block number is 20')).toBeDefined())
-
- const button = await getNextStepButton()
- expect(button).toBeDisabled()
- })
-
- it('Invalid block number: too high', async () => {
- await act(async () => {
- await triggerYes()
- await fillTriggerBlock(99999999999999)
- })
- expect(screen.queryAllByText(/(^The maximum block number is \d*)*/i)).toBeDefined()
- const button = await getNextStepButton()
- expect(button).toBeDisabled()
- })
-
- it('Valid block number', async () => {
- await triggerYes()
- await fillTriggerBlock(30)
-
- expect(await screen.getByText(/^ā.*/i)).toBeDefined()
-
- const button = await getNextStepButton()
- expect(button).not.toBeDisabled()
- })
- })
-
- describe('Discussion Mode - Closed', () => {
- beforeEach(async () => {
- await discussionClosed()
- })
-
- it('Add member to whitelist', async () => {
- await selectFromDropdown('Add member to whitelist', 'alice')
-
- expect(await screen.getByTestId('removeMember')).toBeDefined()
-
- const button = await getNextStepButton()
- expect(button).not.toBeDisabled()
- })
-
- it('Remove member from whitelist', async () => {
- await selectFromDropdown('Add member to whitelist', 'alice')
-
- expect(await screen.getByTestId('removeMember')).toBeDefined()
-
- fireEvent.click(await screen.getByTestId('removeMember'))
- expect(screen.queryByTestId('removeMember')).toBeNull()
-
- const button = await getNextStepButton()
- expect(button).not.toBeDisabled()
- })
- })
- })
- })
-
- describe('Specific parameters', () => {
- const getTxParameters = async () => {
- const [, getTransaction] = findLast(mockedTransactionFee.mock.calls, (params) => params.length > 0) ?? []
- if (!getTransaction) return
-
- createProposalTxMock.mockReset()
- await getTransaction()
- return createProposalTxMock.mock.calls[0]
- }
-
- beforeEach(async () => {
- await finishWarning()
- })
-
- describe('Type - Signal', () => {
- beforeEach(async () => {
- await finishProposalType('signal')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- })
-
- it('Invalid - signal field not filled ', async () => {
- expect(screen.queryByLabelText(/^signal/i)).toHaveValue('')
-
- const button = await getCreateButton()
- expect(button).toBeDisabled()
- })
-
- it('Valid - signal field filled', async () => {
- const signal = 'Foo'
- await SpecificParameters.Signal.fillSignal(signal)
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSignal.toHuman()
- expect(parameters).toEqual(signal)
- const button = await getCreateButton()
- expect(button).toBeEnabled()
- })
- })
-
- describe('Type - Funding Request', () => {
- beforeEach(async () => {
- await finishProposalType('fundingRequest')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- })
-
- it('Invalid - not filled amount, no selected recipient', async () => {
- expect(screen.queryByText('Recipient account')).not.toBeNull()
-
- const button = await getCreateButton()
- expect(button).toBeDisabled()
- })
-
- it('Invalid - no selected recipient', async () => {
- await SpecificParameters.fillAmount(100)
-
- const button = await getCreateButton()
- expect(button).toBeDisabled()
- })
-
- it('Invalid - not filled amount', async () => {
- await SpecificParameters.FundingRequest.selectRecipient('bob')
-
- const button = await getCreateButton()
- expect(button).toBeDisabled()
- })
-
- it('Invalid - amount exceeds max value of 10k', async () => {
- await SpecificParameters.FundingRequest.selectRecipient('bob')
- await SpecificParameters.fillAmount(100_000)
-
- const button = await getCreateButton()
- expect(screen.queryByText(/^Maximal amount allowed is*/)).toBeInTheDocument()
- expect(button).toBeDisabled()
- })
-
- it('Valid - everything filled', async () => {
- const amount = 100
- await SpecificParameters.fillAmount(amount)
- await SpecificParameters.FundingRequest.selectRecipient('bob')
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asFundingRequest.toJSON()
- expect(parameters).toEqual([
- {
- account: getMember('bob').controllerAccount,
- amount: amount,
- },
- ])
- const button = await getCreateButton()
- expect(button).not.toBeDisabled()
- })
- })
-
- describe('Type - Set Referral Cut', () => {
- beforeEach(async () => {
- await finishProposalType('setReferralCut')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- })
-
- it('Default - Invalid', async () => {
- expect(await screen.getByTestId('amount-input')).toHaveValue('0')
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Invalid - over 100 percent', async () => {
- await SpecificParameters.fillAmount(200)
- expect(await screen.getByTestId('amount-input')).toHaveValue('200')
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid', async () => {
- const amount = 40
- await SpecificParameters.fillAmount(amount)
- expect(await screen.getByTestId('amount-input')).toHaveValue(String(amount))
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSetReferralCut.toJSON()
-
- expect(parameters).toEqual(amount)
- expect(await getCreateButton()).toBeEnabled()
- })
-
- it('Valid with execution warning', async () => {
- const amount = 100
- const button = await getCreateButton()
-
- await SpecificParameters.fillAmount(amount)
- expect(await screen.getByTestId('amount-input')).toHaveValue(String(amount))
- expect(button).toBeDisabled()
-
- const checkbox = screen.getByTestId('execution-requirement')
- fireEvent.click(checkbox)
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSetReferralCut.toJSON()
-
- expect(parameters).toEqual(amount)
- expect(button).toBeEnabled()
- })
- })
-
- describe('Type - Decrease Working Group Lead Stake', () => {
- beforeEach(async () => {
- await finishProposalType('decreaseWorkingGroupLeadStake')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- })
-
- it('Default - not filled amount, no selected group', async () => {
- expect(screen.queryByText('Working Group Lead')).toBeNull()
- expect(await getButton(/By half/i)).toBeDisabled()
-
- const button = await getCreateButton()
- expect(button).toBeDisabled()
- })
-
- it('Group selected, amount filled with half stake', async () => {
- await SpecificParameters.DecreaseWorkingGroupLeadStake.selectGroup('Forum')
- await waitFor(async () => expect(await getButton(/By half/i)).not.toBeDisabled())
-
- expect(screen.queryByText(/The actual stake for Forum Working Group Lead is /i)).not.toBeNull()
-
- const button = await getCreateButton()
- expect(button).not.toBeDisabled()
- })
-
- it('Zero amount entered', async () => {
- await SpecificParameters.DecreaseWorkingGroupLeadStake.selectGroup('Forum')
- await waitFor(async () => expect(await getButton(/By half/i)).not.toBeDisabled())
- await SpecificParameters.fillAmount(0)
-
- const button = await getCreateButton()
- expect(button).toBeDisabled()
- })
-
- it('Valid - group selected, amount filled', async () => {
- const amount = 100
- const group = 'Forum'
- await SpecificParameters.DecreaseWorkingGroupLeadStake.selectGroup(group)
- await waitFor(() =>
- expect(screen.queryByText(/The actual stake for Forum Working Group Lead is /i)).not.toBeNull()
- )
- await SpecificParameters.fillAmount(amount)
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asDecreaseWorkingGroupLeadStake.toJSON()
- expect(parameters).toEqual([Number(forumLeadId?.split('-')[1]), amount, group])
-
- const button = await getCreateButton()
- expect(button).not.toBeDisabled()
- })
- })
-
- describe('Type - Terminate Working Group Lead', () => {
- beforeEach(async () => {
- await finishProposalType('terminateWorkingGroupLead')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- })
-
- it('Default - not filled amount, no selected group', async () => {
- expect(await screen.findByLabelText('Working Group', { selector: 'input' })).toHaveValue('')
-
- const button = await getCreateButton()
- expect(button).toBeDisabled()
- })
-
- it('Valid - group selected, amount: not filled/filled', async () => {
- const group = 'Forum'
- const slashingAmount = 100
- await SpecificParameters.TerminateWorkingGroupLead.selectGroup(group)
- const workingGroup = server?.server?.schema.find('WorkingGroup', 'forumWorkingGroup') as any
- const leader = workingGroup?.leader.membership
- expect(await screen.findByText(leader?.handle)).toBeDefined()
-
- const button = await getCreateButton()
- expect(button).not.toBeDisabled()
-
- await triggerYes()
- await SpecificParameters.fillAmount(slashingAmount)
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asTerminateWorkingGroupLead.toJSON()
- expect(parameters).toEqual({
- slashingAmount,
- workerId: Number(forumLeadId?.split('-')[1]),
- group,
- })
-
- expect(button).not.toBeDisabled()
- })
- })
-
- describe('Type - Create Working Group Lead Opening', () => {
- beforeEach(async () => {
- await finishProposalType('createWorkingGroupLeadOpening')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
-
- expect(screen.getByText(/^Create Working Group Lead Opening$/i)).toBeDefined()
- })
-
- it('Step 1: Invalid to Valid', async () => {
- expect(await getNextStepButton()).toBeDisabled()
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.selectGroup('Forum')
- expect(await getNextStepButton()).toBeDisabled()
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillTitle('Foo')
- expect(await getNextStepButton()).toBeDisabled()
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillDescription('Bar')
- expect(await getNextStepButton()).toBeDisabled()
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillShortDescription('Baz')
- expect(await getNextStepButton()).toBeEnabled()
- })
-
- it('Step 2: Invalid to Valid', async () => {
- await SpecificParameters.CreateWorkingGroupLeadOpening.flow({
- group: 'Forum',
- title: 'Foo',
- description: 'Bar',
- shortDesc: 'Baz',
- })
- expect(await getNextStepButton()).toBeDisabled()
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillDetails('Lorem ipsum')
- expect(await getNextStepButton()).toBeEnabled()
-
- await toggleCheckBox(true)
- await fillField('field-period-length', '0')
- expect(await getNextStepButton()).toBeDisabled()
-
- await toggleCheckBox(false)
- expect(await getNextStepButton()).toBeEnabled()
- })
-
- it('Step 3: Invalid to Valid', async () => {
- await SpecificParameters.CreateWorkingGroupLeadOpening.flow(
- { group: 'Forum', title: 'Foo', description: 'Bar', shortDesc: 'Baz' },
- { duration: 100, details: 'Lorem ipsum' }
- )
- expect(await getNextStepButton()).toBeDisabled()
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillQuestionField('š?', 0)
- expect(await getNextStepButton()).toBeEnabled()
-
- const addQuestionBtn = await screen.findByText('Add new question')
- act(() => {
- fireEvent.click(addQuestionBtn)
- })
- expect(await getNextStepButton()).toBeDisabled()
-
- await toggleCheckBox(false, 1)
- expect(await getNextStepButton()).toBeDisabled()
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillQuestionField('š?', 1)
- expect(await getNextStepButton()).toBeEnabled()
- })
-
- it('Step 4: Invalid to valid', async () => {
- const step1 = { group: 'Storage', title: 'Foo', description: 'Bar', shortDesc: 'Baz' }
- const step2 = { duration: 100, details: 'Lorem ipsum' }
- const step3 = {
- questions: [
- { question: 'Short?', type: QUESTION_INPUT.TEXT },
- { question: 'Long?', type: QUESTION_INPUT.TEXTAREA },
- ],
- }
- const step4 = { stake: 100, unstakingPeriod: 101, rewardPerBlock: 102 }
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.flow(step1, step2, step3)
- expect(await getCreateButton()).toBeDisabled()
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillStakingAmount(step4.stake)
- expect(await getCreateButton()).toBeDisabled()
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillUnstakingPeriod(step4.unstakingPeriod)
- expect(await getCreateButton()).toBeDisabled()
-
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillRewardPerBlock(step4.rewardPerBlock)
- expect(await getCreateButton()).toBeEnabled()
-
- const [, txSpecificParameters] = await getTxParameters()
-
- const { description: metadata, ...data } = txSpecificParameters.asCreateWorkingGroupLeadOpening.toJSON()
- expect(data).toEqual({
- rewardPerBlock: step4.rewardPerBlock,
- stakePolicy: {
- stakeAmount: step4.stake,
- leavingUnstakingPeriod: step4.unstakingPeriod,
- },
- group: step1.group,
- })
-
- expect(metadataFromBytes(OpeningMetadata, metadata)).toEqual({
- title: step1.title,
- shortDescription: step1.shortDesc,
- description: step1.description,
- hiringLimit: 1,
- expectedEndingTimestamp: step2.duration,
- applicationDetails: step2.details,
- applicationFormQuestions: step3.questions,
- })
- })
- })
-
- describe('Type - Set Working Group Lead Reward', () => {
- beforeEach(async () => {
- await finishProposalType('setWorkingGroupLeadReward')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
-
- expect(screen.getByText(/^Set Working Group Lead Reward$/i)).toBeDefined()
- })
-
- it('Invalid form', async () => {
- expect(await screen.queryByLabelText(/^Working Group$/i, { selector: 'input' })).toHaveValue('')
- expect(await screen.queryByTestId('amount-input')).toHaveValue('')
- expect(await getCreateButton()).toBeDisabled()
-
- await SpecificParameters.SetWorkingGroupLeadReward.fillRewardAmount(0)
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid form', async () => {
- const group = 'Forum'
- const amount = 100
- await SpecificParameters.SetWorkingGroupLeadReward.selectGroup(group)
- await SpecificParameters.SetWorkingGroupLeadReward.fillRewardAmount(amount)
- expect(await getCreateButton()).toBeEnabled()
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSetWorkingGroupLeadReward.toJSON()
- expect(parameters).toEqual([Number(forumLeadId?.split('-')[1]), amount, group])
- })
- })
-
- describe('Type - Set Max Validator Count', () => {
- beforeEach(async () => {
- await finishProposalType('setMaxValidatorCount')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
-
- expect(screen.getByText(/^Set max validator count$/i)).toBeDefined()
- })
-
- it('Invalid form', async () => {
- expect(await screen.queryByTestId('amount-input')).toHaveValue('0')
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Validate max and min value', async () => {
- await SpecificParameters.fillAmount(400)
- expect(await screen.queryByText('Maximal amount allowed is'))
-
- await SpecificParameters.fillAmount(0)
- expect(await screen.queryByText('Minimal amount allowed is'))
- })
-
- it('Valid form', async () => {
- const amount = 100
- await SpecificParameters.fillAmount(amount)
- expect(await getCreateButton()).toBeEnabled()
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSetMaxValidatorCount.toJSON()
- await waitFor(() => expect(parameters).toEqual(amount))
- })
- })
-
- describe('Type - Cancel Working Group Lead Opening', () => {
- beforeEach(async () => {
- await finishProposalType('cancelWorkingGroupLeadOpening')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
-
- expect(screen.getByText(/^Cancel Working Group Lead Opening$/i)).toBeDefined()
- })
-
- it('Invalid form', async () => {
- expect(await screen.queryByLabelText(/^Opening/i, { selector: 'input' })).toHaveValue('')
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid form', async () => {
- await SpecificParameters.CancelWorkingGroupLeadOpening.selectedOpening('forumWorkingGroup-1337')
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asCancelWorkingGroupLeadOpening.toJSON()
- expect(parameters).toEqual([1337, 'Forum'])
- expect(await getCreateButton()).toBeEnabled()
- })
- })
-
- describe('Type - Set Council Budget Increment', () => {
- beforeEach(async () => {
- await finishProposalType('setCouncilBudgetIncrement')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
-
- expect(screen.getByText(/^Set Council Budget Increment$/i)).toBeDefined()
- })
-
- it('Invalid form', async () => {
- expect(await screen.queryByTestId('amount-input')).toHaveValue('')
- expect(await screen.queryByTestId('amount-input')).toBeEnabled()
- expect(await getCreateButton()).toBeDisabled()
-
- await SpecificParameters.fillAmount(0)
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Validate max value', async () => {
- await SpecificParameters.fillAmount(powerOf2(128))
- expect(await screen.queryByTestId('amount-input')).toHaveValue('')
- expect(await screen.queryByTestId('amount-input')).toBeEnabled()
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid form', async () => {
- const amount = 100
- await SpecificParameters.fillAmount(amount)
- expect(await getCreateButton()).toBeEnabled()
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSetCouncilBudgetIncrement.toJSON()
- expect(parameters).toEqual(amount)
- })
- })
-
- describe('Type - Set Councilor Reward', () => {
- beforeEach(async () => {
- await finishProposalType('setCouncilorReward')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
-
- expect(screen.getByText(/^Set Councilor Reward$/i)).toBeDefined()
- })
-
- it('Invalid form', async () => {
- expect(await screen.queryByTestId('amount-input')).toHaveValue('')
- expect(await screen.queryByTestId('amount-input')).toBeEnabled()
- expect(await getCreateButton()).toBeDisabled()
-
- await SpecificParameters.fillAmount(0)
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid form', async () => {
- const amount = 100
- await SpecificParameters.fillAmount(amount)
- expect(await getCreateButton()).toBeEnabled()
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSetCouncilorReward.toJSON()
- expect(parameters).toEqual(amount)
- })
- })
-
- describe('Type - Set Membership lead invitation quota proposal', () => {
- beforeEach(async () => {
- await finishProposalType('setMembershipLeadInvitationQuota')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
-
- expect(screen.getByText(/^Set Membership Lead Invitation Quota$/i)).toBeDefined()
- })
-
- it('Invalid form', async () => {
- await waitFor(async () => expect(await screen.queryByTestId('amount-input')).toBeEnabled())
- expect(await screen.queryByTestId('amount-input')).toHaveValue('0')
- expect(await screen.queryByTestId('amount-input')).toBeEnabled()
- expect(await getCreateButton()).toBeDisabled()
-
- await SpecificParameters.fillAmount(0)
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Validate max value', async () => {
- await waitFor(async () => expect(await screen.queryByTestId('amount-input')).toBeEnabled())
- await SpecificParameters.fillAmount(powerOf2(32))
- expect(screen.queryByTestId('amount-input')).toHaveValue('0')
- expect(screen.queryByTestId('amount-input')).toBeEnabled()
- })
-
- it('Valid form', async () => {
- const amount = 100
- await waitFor(async () => expect(await screen.queryByTestId('amount-input')).toBeEnabled())
- await SpecificParameters.fillAmount(amount)
- expect(await getCreateButton()).toBeEnabled()
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSetMembershipLeadInvitationQuota.toJSON()
- expect(parameters).toEqual(amount)
- })
- })
- describe('Type - Fill Working Group Lead Opening', () => {
- beforeEach(async () => {
- await finishProposalType('fillWorkingGroupLeadOpening')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
-
- expect(screen.getByText(/^Fill Working Group Lead Opening$/i)).toBeDefined()
- })
-
- it('Invalid form', async () => {
- expect(await screen.queryByLabelText(/^Opening/i, { selector: 'input' })).toHaveValue('')
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid form', async () => {
- await SpecificParameters.FillWorkingGroupLeadOpening.selectedOpening('forumWorkingGroup-1337')
- await SpecificParameters.FillWorkingGroupLeadOpening.selectApplication(
- `Member ID: ${APPLICATION_DATA.applicantId}`
- )
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asFillWorkingGroupLeadOpening.toJSON()
- expect(parameters).toEqual({
- openingId: 1337,
- applicationId: 1337,
- workingGroup: 'Forum',
- })
- expect(await getCreateButton()).toBeEnabled()
- })
- })
- describe('Type - Set Initial Invitation Count', () => {
- beforeAll(() => {
- stubQuery(api, 'members.initialInvitationCount', createType('u32', 13))
- })
-
- beforeEach(async () => {
- await finishProposalType('setInitialInvitationCount')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
-
- expect(screen.getByText(/^Set Initial Invitation Count$/i)).toBeDefined()
- })
-
- it('Displays current invitations count', async () => {
- expect(await screen.findByText('The current initial invitation count is 13.')).toBeDefined()
- })
-
- it('Invalid form', async () => {
- expect(await screen.findByLabelText(/^New Count$/i, { selector: 'input' })).toHaveValue('0')
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid form', async () => {
- const count = 1
- await SpecificParameters.SetInitialInvitationCount.fillCount(1)
- expect(await getCreateButton()).toBeEnabled()
- await SpecificParameters.SetInitialInvitationCount.fillCount(count)
- expect(await getCreateButton()).toBeEnabled()
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSetInitialInvitationCount.toJSON()
- expect(parameters).toEqual(count)
- })
- })
- describe('Type - Set Initial Invitation Balance', () => {
- beforeAll(() => {
- stubQuery(api, 'members.initialInvitationBalance', createType('Balance', 2137))
- })
-
- beforeEach(async () => {
- await finishProposalType('setInitialInvitationBalance')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
-
- expect(screen.getByText(/^Set Initial Invitation Balance$/i)).toBeDefined()
- })
-
- it('Invalid form', async () => {
- expect(await screen.queryByTestId('amount-input')).toHaveValue('')
- expect(await getCreateButton()).toBeDisabled()
-
- await SpecificParameters.fillAmount(0)
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid form', async () => {
- const amount = 1000
- await SpecificParameters.fillAmount(amount)
- expect(await getCreateButton()).toBeEnabled()
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSetInitialInvitationBalance.toJSON()
- expect(parameters).toEqual(amount)
- })
-
- it('Displays current balance', async () => {
- expect(await screen.queryByText(`The current balance is 2137 ${CurrencyName.integerValue}.`)).toBeDefined()
- })
- })
- describe('Type - Set Membership price', () => {
- beforeEach(async () => {
- await finishProposalType('setMembershipPrice')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- })
-
- it('Default - Invalid', async () => {
- expect(await screen.getByTestId('amount-input')).toHaveValue('')
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid', async () => {
- const price = 100
- await SpecificParameters.fillAmount(price)
- expect(await getCreateButton()).toBeEnabled()
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asSetMembershipPrice.toJSON()
- expect(parameters).toEqual(price)
- })
- })
-
- describe('Type - Update Working Group Budget', () => {
- beforeEach(async () => {
- stubQuery(api, 'council.budget', new BN(2500))
- await finishProposalType('updateWorkingGroupBudget')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- })
-
- it('Default - no selected group, amount not filled', async () => {
- expect(await screen.findByLabelText('Working Group', { selector: 'input' })).toHaveValue('')
-
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Invalid - group selected, positive amount bigger than current council budget', async () => {
- await SpecificParameters.UpdateWorkingGroupBudget.selectGroup('Forum')
- await waitFor(() => expect(screen.queryByText(/Current budget for Forum Working Group is /i)).not.toBeNull())
- await SpecificParameters.fillAmount(3000)
-
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid - group selected, amount automatically filled', async () => {
- await SpecificParameters.UpdateWorkingGroupBudget.selectGroup('Forum')
- await waitFor(() => expect(screen.queryByText(/Current budget for Forum Working Group is /i)).not.toBeNull())
-
- expect(await getCreateButton()).not.toBeDisabled()
- })
-
- it('Valid - group selected, amount bigger than current stake filled', async () => {
- await SpecificParameters.UpdateWorkingGroupBudget.selectGroup('Forum')
- await waitFor(() => expect(screen.queryByText(/Current budget for Forum Working Group is /i)).not.toBeNull())
- await SpecificParameters.fillAmount(1000)
-
- expect(await getCreateButton()).toBeEnabled()
- })
-
- it('Invaild - group selected, negative amount bigger than current WG budget', async () => {
- await SpecificParameters.UpdateWorkingGroupBudget.selectGroup('Forum')
- await waitFor(() => expect(screen.queryByText(/Current budget for Forum Working Group is /i)).not.toBeNull())
-
- // Switch to 'Decrease budget', input will be handled as negative
- await triggerYes()
- await SpecificParameters.fillAmount(999999)
-
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid - group selected, negative amount filled', async () => {
- const amount = 100
- const group = 'Forum'
- await SpecificParameters.UpdateWorkingGroupBudget.selectGroup('Forum')
- await waitFor(() => expect(screen.queryByText(/Current budget for Forum Working Group is /i)).not.toBeNull())
-
- // Switch to 'Decrease budget', input will be handled as negative
- await triggerYes()
- await SpecificParameters.fillAmount(amount)
-
- expect(await getCreateButton()).toBeEnabled()
-
- const [, txSpecificParameters] = await getTxParameters()
- const parameters = txSpecificParameters.asUpdateWorkingGroupBudget.toJSON()
- expect(parameters).toEqual([amount, group, 'Negative'])
- })
- })
-
- describe('Type - Runtime Upgrade', () => {
- beforeEach(async () => {
- await finishProposalType('runtimeUpgrade')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- })
-
- it('Default - Invalid', async () => {
- expect(await getCreateButton()).toBeDisabled()
- })
-
- it('Valid', async () => {
- const file = Object.defineProperties(new File([], 'runtime.wasm', { type: 'application/wasm' }), {
- isValidWASM: { value: true },
- arrayBuffer: { value: () => Promise.resolve(new ArrayBuffer(1)) },
- size: { value: 1 },
- })
-
- await act(async () => {
- fireEvent.change(screen.getByTestId('runtime-upgrade-input'), { target: { files: [file] } })
- })
-
- expect(await getCreateButton()).toBeEnabled()
- })
- })
- })
-
- describe('Authorize', () => {
- it('Fee fail before transaction', async () => {
- await finishWarning()
- await finishProposalType('fundingRequest')
- const requiredStake = 10
- stubProposalConstants(api, { requiredStake })
- stubTransaction(api, 'api.tx.utility.batch', 10000)
- mockTransactionFee({ feeInfo: { transactionFee: new BN(10000), canAfford: false } })
-
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- await SpecificParameters.FundingRequest.finish(100, 'bob')
-
- const moveFundsModalCall: MoveFundsModalCall = {
- modal: 'MoveFundsModal',
- data: {
- requiredStake: new BN(requiredStake),
- lock: 'Proposals',
- isFeeOriented: true,
- },
- }
-
- expect(showModal).toBeCalledWith({ ...moveFundsModalCall })
- })
-
- describe('Staking account not bound nor staking candidate', () => {
- beforeEach(async () => {
- mockTransactionFee({ transaction: batchTx })
- await finishWarning()
- await finishProposalType('fundingRequest')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- await SpecificParameters.FundingRequest.finish(100, 'bob')
- })
-
- it('Bind account step', async () => {
- expect(await screen.findByText('You intend to bind account for staking')).toBeDefined()
- expect((await screen.findByText(/^modals.transactionFee.label/i))?.nextSibling?.textContent).toBe('42')
- })
-
- it('Bind account failure', async () => {
- stubTransactionFailure(bindAccountTx)
-
- await act(async () => {
- fireEvent.click(screen.getByText(/^Sign transaction/i))
- })
-
- expect(await screen.findByText('Failure')).toBeDefined()
- })
-
- it('Create proposal step', async () => {
- stubTransactionSuccess(bindAccountTx, 'members', 'StakingAccountAdded')
-
- await act(async () => {
- fireEvent.click(screen.getByText(/^Sign transaction/i))
- })
-
- expect(await screen.findByText(/You intend to create a proposal/i)).toBeDefined()
- expect(screen.getByText(/modals\.transactionFee\.label/i)?.nextSibling?.textContent).toBe('25')
- })
-
- it('Create proposal success', async () => {
- stubTransactionSuccess(bindAccountTx, 'members', 'StakingAccountAdded')
- await act(async () => {
- fireEvent.click(screen.getByText(/^Sign transaction/i))
- })
- stubTransactionSuccess(batchTx, 'proposalsCodex', 'ProposalCreated', [createType('ProposalId', 1337)])
-
- await act(async () => {
- fireEvent.click(await screen.findByText(/^Sign transaction and Create$/i))
- })
-
- expect(await screen.findByText('See my Proposal')).toBeDefined()
- })
-
- it('Create proposal failure', async () => {
- stubTransactionSuccess(bindAccountTx, 'members', 'StakingAccountAdded')
- await act(async () => {
- fireEvent.click(screen.getByText(/^Sign transaction/i))
- })
- stubTransactionFailure(batchTx)
-
- await act(async () => {
- fireEvent.click(await screen.findByText(/^Sign transaction and Create$/i))
- })
-
- expect(await screen.findByText('Failure')).toBeDefined()
- })
- })
-
- describe('Staking account is a candidate', () => {
- beforeEach(async () => {
- mockTransactionFee({ transaction: batchTx })
- stubQuery(
- api,
- 'members.stakingAccountIdMemberStatus',
- createType('PalletMembershipStakingAccountMemberBinding', {
- memberId: createType('MemberId', 0),
- confirmed: createType('bool', false),
- })
- )
- stubQuery(api, 'members.stakingAccountIdMemberStatus.size', createType('u64', 8))
-
- await finishWarning()
- await finishProposalType('fundingRequest')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- await SpecificParameters.FundingRequest.finish(100, 'bob')
- })
-
- it('Create proposal step', async () => {
- expect(await screen.findByText(/You intend to create a proposa/i)).not.toBeNull()
- expect((await screen.findByText(/^modals.transactionFee.label/i))?.nextSibling?.textContent).toBe('25')
- })
-
- it('Create proposal success', async () => {
- stubTransactionSuccess(batchTx, 'proposalsCodex', 'ProposalCreated', [createType('ProposalId', 1337)])
-
- await act(async () => {
- fireEvent.click(await screen.getByText(/^Sign transaction and Create$/i))
- })
-
- expect(await screen.findByText('See my Proposal')).toBeDefined()
- })
-
- it('Create proposal failure', async () => {
- stubTransactionFailure(batchTx)
-
- await act(async () => {
- fireEvent.click(await screen.getByText(/^Sign transaction and Create$/i))
- })
-
- expect(await screen.findByText('Failure')).toBeDefined()
- })
- })
-
- describe('Staking account is confirmed', () => {
- beforeEach(async () => {
- mockTransactionFee({ transaction: createProposalTx })
- stubQuery(
- api,
- 'members.stakingAccountIdMemberStatus',
- createType('PalletMembershipStakingAccountMemberBinding', {
- memberId: createType('MemberId', 0),
- confirmed: createType('bool', true),
- })
- )
- stubQuery(api, 'members.stakingAccountIdMemberStatus.size', createType('u64', 8))
-
- await finishWarning()
- await finishProposalType('fundingRequest')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion()
- await SpecificParameters.FundingRequest.finish(100, 'bob')
- })
-
- it('Create proposal step', async () => {
- expect(await screen.findByText(/You intend to create a proposa/i)).not.toBeNull()
- expect((await screen.findByText(/^modals.transactionFee.label/i))?.nextSibling?.textContent).toBe('25')
- })
-
- it('Create proposal success', async () => {
- stubTransactionSuccess(createProposalTx, 'proposalsCodex', 'ProposalCreated', [
- createType('ProposalId', 1337),
- ])
-
- await act(async () => {
- fireEvent.click(await screen.getByText(/^Sign transaction and Create$/i))
- })
-
- expect(await screen.findByText('See my Proposal')).toBeDefined()
- })
-
- it('Create proposal failure', async () => {
- stubTransactionFailure(createProposalTx)
-
- await act(async () => {
- fireEvent.click(await screen.getByText(/^Sign transaction and Create$/i))
- })
-
- expect(await screen.findByText('Failure')).toBeDefined()
- })
- })
- })
-
- it('Previous step', async () => {
- await finishWarning()
- await finishProposalType()
- await finishStakingAccount()
- await finishProposalDetails()
-
- expect(screen.queryByText('Discussion mode:')).not.toBeNull()
- await clickPreviousButton()
-
- expect(screen.queryByDisplayValue('Some title')).not.toBeNull()
- await clickPreviousButton()
-
- expect(screen.queryByText('Select account for Staking')).not.toBeNull()
- expect(await getNextStepButton()).not.toBeDisabled()
- await clickPreviousButton()
-
- expect(screen.queryByText('Please choose proposal type')).not.toBeNull()
- expect(await getNextStepButton()).not.toBeDisabled()
- })
-
- describe('Discussion mode transaction', () => {
- beforeEach(async () => {
- mockTransactionFee({ transaction: createProposalTx })
- stubQuery(
- api,
- 'members.stakingAccountIdMemberStatus',
- createType('PalletMembershipStakingAccountMemberBinding', {
- memberId: createType('MemberId', 0),
- confirmed: createType('bool', true),
- })
- )
- stubQuery(api, 'members.stakingAccountIdMemberStatus.size', createType('u64', 8))
- stubTransactionSuccess(createProposalTx, 'proposalsCodex', 'ProposalCreated', [createType('ProposalId', 1337)])
- await finishWarning()
- await finishProposalType('fundingRequest')
- await finishStakingAccount()
- await finishProposalDetails()
- await finishTriggerAndDiscussion(true)
- await SpecificParameters.FundingRequest.finish(100, 'bob')
-
- await act(async () => {
- fireEvent.click(await screen.getByText(/^Sign transaction and Create$/i))
- })
- })
-
- it('Arrives at the transaction modal', async () => {
- expect(await screen.findByText(/You intend to change the proposal discussion thread mode./i)).toBeDefined()
- expect(await screen.findByText(/Sign transaction and change mode/i)).toBeDefined()
- })
-
- it('Success', async () => {
- stubTransactionSuccess(changeModeTx, 'proposalsDiscussion', 'ThreadModeChanged')
- const button = await getButton(/sign transaction and change mode/i)
- await act(async () => {
- fireEvent.click(button)
- })
- expect(await screen.findByText('See my Proposal')).toBeDefined()
- })
-
- it('Failure', async () => {
- stubTransactionFailure(changeModeTx)
- const button = await getButton(/sign transaction and change mode/i)
-
- fireEvent.click(button)
-
- expect(await screen.findByText('Failure')).toBeDefined()
- })
- })
- })
-
- const getCheckbox = async () =>
- await screen.queryByText('Iām aware of the possible risks associated with creating a proposal.')
-
- async function finishWarning() {
- await renderModal()
-
- const button = await getWarningNextButton()
-
- const checkbox = await getCheckbox()
- fireEvent.click(checkbox as HTMLElement)
- fireEvent.click(button as HTMLElement)
- }
-
- async function finishProposalType(type?: ProposalType) {
- const typeElement = (await screen.findByText(camelCaseToText(type || 'fundingRequest'))).parentElement
- ?.parentElement as HTMLElement
- fireEvent.click(typeElement)
-
- await clickNextButton()
- }
-
- async function finishStakingAccount() {
- await selectFromDropdown('Select account for Staking', 'alice')
-
- await clickNextButton()
- }
-
- async function finishProposalDetails() {
- await fillProposalDetails()
-
- await clickNextButton()
- }
-
- async function finishTriggerAndDiscussion(closeDiscussion = false) {
- if (closeDiscussion) {
- await discussionClosed()
- }
- await clickNextButton()
- }
-
- async function fillProposalDetails() {
- const titleInput = await screen.findByLabelText(/Proposal title/i)
- fireEvent.change(titleInput, { target: { value: 'Some title' } })
-
- const rationaleInput = await screen.findByLabelText(/Rationale/i)
- fireEvent.change(rationaleInput, { target: { value: 'Some rationale' } })
- }
-
- async function triggerYes() {
- const triggerToggle = await screen.findByText('Yes')
- fireEvent.click(triggerToggle)
- }
-
- async function fillTriggerBlock(value: number) {
- const blockInput = await screen.getByTestId('triggerBlock')
- fireEvent.change(blockInput, { target: { value } })
- }
-
- async function discussionClosed() {
- const discussionToggle = (await screen.findAllByRole('checkbox'))[1]
- fireEvent.click(discussionToggle)
- }
-
- async function getWarningNextButton() {
- return await getButton('Create A Proposal')
- }
-
- async function getPreviousStepButton() {
- return await getButton(/Previous step/i)
- }
-
- async function clickPreviousButton() {
- const button = await getPreviousStepButton()
- fireEvent.click(button as HTMLElement)
- }
-
- async function getNextStepButton() {
- return getButton(/Next step/i)
- }
-
- async function getCreateButton() {
- return getButton(/Create proposal/i)
- }
-
- async function clickNextButton() {
- const button = await getNextStepButton()
- fireEvent.click(button as HTMLElement)
- }
-
- const selectGroup = async (name: string) => {
- await selectFromDropdown('^Working Group$', name)
- }
-
- const selectedOpening = async (name: string) => {
- await selectFromDropdown('^Opening$', name)
- }
-
- const selectApplication = async (name: string) => {
- await selectFromDropdown('^Application$', name)
- }
-
- async function fillField(id: string, value: number | BN | string) {
- const amountInput = screen.getByTestId(id)
- act(() => {
- fireEvent.change(amountInput, { target: { value: String(value) } })
- })
- }
-
- const SpecificParameters = {
- fillAmount: async (value: number | BN) => await fillField('amount-input', String(value)),
- Signal: {
- fillSignal: async (value: string) => await fillField('signal', value),
- },
- FundingRequest: {
- selectRecipient: async (name: string) => {
- await selectFromDropdown('Recipient account', name)
- },
- finish: async (amount: number, recipient: string) => {
- await SpecificParameters.fillAmount(amount)
- await SpecificParameters.FundingRequest.selectRecipient(recipient)
-
- const button = await getCreateButton()
- act(() => {
- fireEvent.click(button as HTMLElement)
- })
- },
- },
- DecreaseWorkingGroupLeadStake: {
- selectGroup,
- },
- TerminateWorkingGroupLead: {
- selectGroup,
- },
- CreateWorkingGroupLeadOpening: {
- selectGroup,
- fillTitle: async (value: string) => await fillField('opening-title', value),
- fillShortDescription: async (value: string) => await fillField('short-description', value),
- fillDescription: async (value: string) => await fillField('field-description', value),
- fillDuration: async (value: number | undefined) => {
- await toggleCheckBox(!!value)
- if (value) await fillField('field-period-length', value)
- },
- fillDetails: async (value: string) => await fillField('field-details', value),
- fillQuestionField: async (value: string, index: number) => {
- const field = (await screen.findAllByRole('textbox'))[index]
- act(() => {
- fireEvent.change(field, { target: { value } })
- })
- },
- fillQuestions: async (value: OpeningMetadata.IApplicationFormQuestion[]) => {
- const addQuestionBtn = await screen.findByText('Add new question')
-
- for (let index = 0; index < value.length; index++) {
- if (index > 0)
- act(() => {
- fireEvent.click(addQuestionBtn)
- })
-
- const question = value[index].question ?? ''
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillQuestionField(question, index)
-
- await toggleCheckBox(value[index].type === QUESTION_INPUT.TEXT, index)
- }
- },
- fillUnstakingPeriod: async (value: number) => await fillField('leaving-unstaking-period', value),
- fillStakingAmount: async (value: number) => await fillField('staking-amount', value),
- fillRewardPerBlock: async (value: number) => await fillField('reward-per-block', value),
- flow: async (
- step1?: { group: string; title: string; description: string; shortDesc: string },
- step2?: { duration: number | undefined; details: string },
- step3?: { questions: OpeningMetadata.IApplicationFormQuestion[] },
- step4?: { stake: number; unstakingPeriod: number; rewardPerBlock: number }
- ) => {
- if (!step1) return
- await SpecificParameters.CreateWorkingGroupLeadOpening.selectGroup(step1.group)
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillTitle(step1.title)
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillDescription(step1.description)
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillShortDescription(step1.shortDesc)
- await clickNextButton()
-
- if (!step2) return
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillDuration(step2.duration)
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillDetails(step2.details)
- await clickNextButton()
-
- if (!step3) return
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillQuestions(step3.questions)
- await clickNextButton()
-
- if (!step4) return
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillStakingAmount(step4.stake)
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillUnstakingPeriod(step4.unstakingPeriod)
- await SpecificParameters.CreateWorkingGroupLeadOpening.fillRewardPerBlock(step4.rewardPerBlock)
-
- const createButton = await getCreateButton()
- await act(async () => {
- fireEvent.click(createButton as HTMLElement)
- })
- },
- },
- CancelWorkingGroupLeadOpening: {
- selectedOpening,
- },
- SetWorkingGroupLeadReward: {
- selectGroup,
- fillRewardAmount: async (value: number) => await fillField('amount-input', value),
- },
- FillWorkingGroupLeadOpening: {
- selectedOpening,
- selectApplication,
- },
- UpdateWorkingGroupBudget: {
- selectGroup,
- },
- SetInitialInvitationCount: {
- fillCount: async (value: number) => await fillField('count-input', value),
- },
- }
-
- async function renderModal() {
- return await render(
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
- }
-})
diff --git a/packages/ui/test/proposals/modals/VoteForProposalModal.test.tsx b/packages/ui/test/proposals/modals/VoteForProposalModal.test.tsx
deleted file mode 100644
index a13d101f6f..0000000000
--- a/packages/ui/test/proposals/modals/VoteForProposalModal.test.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import { cryptoWaitReady } from '@polkadot/util-crypto'
-import { act, configure, fireEvent, render, screen, waitFor } from '@testing-library/react'
-import BN from 'bn.js'
-import React from 'react'
-import { MemoryRouter } from 'react-router'
-
-import { ApiContext } from '@/api/providers/context'
-import { CKEditorProps } from '@/common/components/CKEditor'
-import { ModalContextProvider } from '@/common/providers/modal/provider'
-import { MembershipContext } from '@/memberships/providers/membership/context'
-import { MyMemberships } from '@/memberships/providers/membership/provider'
-import { seedMembers, seedProposal } from '@/mocks/data'
-import { getMember } from '@/mocks/helpers'
-import { VoteForProposalModal } from '@/proposals/modals/VoteForProposal'
-
-import { getButton } from '../../_helpers/getButton'
-import { mockCKEditor } from '../../_mocks/components/CKEditor'
-import { alice, bob } from '../../_mocks/keyring'
-import { MockKeyringProvider, MockQueryNodeProviders } from '../../_mocks/providers'
-import { setupMockServer } from '../../_mocks/server'
-import { PROPOSAL_DATA } from '../../_mocks/server/seeds'
-import {
- currentStubErrorMessage,
- stubAccounts,
- stubApi,
- stubDefaultBalances,
- stubTransaction,
- stubTransactionFailure,
- stubTransactionSuccess,
-} from '../../_mocks/transactions'
-import { mockTransactionFee, mockUseModalCall } from '../../setup'
-
-configure({ testIdAttribute: 'id' })
-
-jest.mock('@/common/components/CKEditor', () => ({
- CKEditor: (props: CKEditorProps) => mockCKEditor(props),
-}))
-
-describe('UI: Vote for Proposal Modal', () => {
- const api = stubApi()
- const useMyMemberships: MyMemberships = {
- active: getMember('alice'),
- members: [getMember('alice')],
- setActive: (member) => (useMyMemberships.active = member),
- isLoading: false,
- hasMembers: true,
- helpers: {
- getMemberIdByBoundAccountAddress: () => undefined,
- },
- }
-
- const server = setupMockServer({ noCleanupAfterEach: true })
-
- let tx: any
-
- beforeAll(async () => {
- mockUseModalCall({ modalData: { id: '0' } })
- await cryptoWaitReady()
- seedMembers(server.server, 2)
- seedProposal(PROPOSAL_DATA, server.server)
- stubAccounts([alice, bob])
- })
-
- beforeEach(() => {
- tx = stubTransaction(api, 'api.tx.proposalsEngine.vote', 100)
- mockTransactionFee({ feeInfo: { transactionFee: new BN(100), canAfford: true } })
- stubDefaultBalances()
- })
-
- it('Requirements verification', async () => {
- tx = stubTransaction(api, 'api.tx.proposalsEngine.vote', 10_000)
- mockTransactionFee({ feeInfo: { transactionFee: new BN(100), canAfford: false } })
-
- await renderModal(true)
-
- expect(await screen.findByText('modals.insufficientFunds.title')).toBeDefined()
- })
-
- it('Renders a modal', async () => {
- await renderModal()
-
- expect(screen.queryByText(/Vote for proposal/i)).not.toBeNull()
- expect(screen.queryByText(PROPOSAL_DATA.title)).not.toBeNull()
- })
-
- describe('Form', () => {
- it('Empty', async () => {
- await renderModal()
-
- expect(await getButton(/^sign transaction and vote/i)).toBeDisabled()
- expect(await getButton(/^Reject/i)).toBeDefined()
- expect(await getButton(/^Approve/i)).toBeDefined()
- expect(await getButton(/^Abstain/i)).toBeDefined()
- })
-
- it('No rationale', async () => {
- await renderModal()
-
- await act(async () => {
- fireEvent.click(await getButton(/^Approve/i))
- })
-
- expect(await getButton(/^sign transaction and vote/i)).toBeDisabled()
- })
-
- it('No vote type selected', async () => {
- await renderModal()
-
- await fillRationale()
-
- expect(await getButton(/^sign transaction and vote/i)).toBeDisabled()
- })
-
- it('Filled', async () => {
- await renderModal()
-
- await act(async () => {
- fireEvent.click(await getButton(/^Approve/i))
- })
- await fillRationale()
-
- expect(await getButton(/^sign transaction and vote/i)).not.toBeDisabled()
- })
-
- it('Vote Status: Reject', async () => {
- await renderModal()
-
- await act(async () => {
- fireEvent.click(await getButton(/^Reject/i))
- })
- expect(screen.queryByText(/Slash proposal/i)).not.toBeNull()
- })
- })
-
- describe('Transaction', () => {
- async function beforeEach(enoughFunds: boolean) {
- if (!enoughFunds) {
- tx = stubTransaction(api, 'api.tx.proposalsEngine.vote', 1500)
- }
-
- await renderModal()
- await act(async () => {
- fireEvent.click(await getButton(/^Approve/i))
- })
- await fillRationale()
- await act(async () => {
- fireEvent.click(await getButton(/^sign transaction and vote/i))
- })
- }
-
- describe('Renders', () => {
- it('Enough funds', async () => {
- await beforeEach(true)
-
- expect(await getButton(/^sign transaction and vote/i)).not.toBeDisabled()
- expect(screen.queryByText(/^(.*?)You need at least \d+ tJOY(.*)/i)).toBeNull()
- })
- })
-
- it('Success', async () => {
- await beforeEach(true)
- stubTransactionSuccess(tx, 'proposalsEngine', 'Voted')
-
- await act(async () => {
- fireEvent.click(await getButton(/^sign transaction and vote/i))
- })
-
- expect(await screen.findByText('Success')).toBeDefined()
- expect(await getButton(/Back to proposals/i)).toBeDefined()
- })
-
- it('Error', async () => {
- await beforeEach(true)
- stubTransactionFailure(tx)
-
- await act(async () => {
- fireEvent.click(await getButton(/^sign transaction and vote/i))
- })
-
- expect(await screen.findByText('Failure')).toBeDefined()
- expect(await screen.findByText(currentStubErrorMessage)).toBeDefined()
- })
- })
-
- const fillRationale = async () => {
- const rationaleInput = await screen.findByLabelText(/Rationale/i)
- act(() => {
- fireEvent.change(rationaleInput, { target: { value: 'Some rationale' } })
- })
- }
-
- async function renderModal(skipWait?: boolean) {
- await render(
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
-
- if (!skipWait) {
- await waitFor(async () => expect(await screen.findByText('Vote for proposal')).toBeDefined())
- }
- }
-})