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 ( + <> +