diff --git a/packages/ui/.storybook/preview.tsx b/packages/ui/.storybook/preview.tsx index 810cd97b2c..8ba958bc6b 100644 --- a/packages/ui/.storybook/preview.tsx +++ b/packages/ui/.storybook/preview.tsx @@ -16,6 +16,7 @@ import { TransactionStatusProvider } from '../src/common/providers/transactionSt import { MockProvidersDecorator, MockRouterDecorator } from '../src/mocks/providers' import { i18next } from '../src/services/i18n' import { KeyringContext } from '../src/common/providers/keyring/context' +import { ValidatorContextProvider } from '../src/validators/providers/provider' import { Keyring } from '@polkadot/ui-keyring' configure({ testIdAttribute: 'id' }) @@ -56,11 +57,13 @@ const ModalDecorator: Decorator = (Story) => ( - - - - - + + + + + + + diff --git a/packages/ui/src/accounts/model/encodeAddress.ts b/packages/ui/src/accounts/model/encodeAddress.ts index d2ea022aac..25125fb99c 100644 --- a/packages/ui/src/accounts/model/encodeAddress.ts +++ b/packages/ui/src/accounts/model/encodeAddress.ts @@ -1,5 +1,5 @@ import { encodeAddress as encode } from '@polkadot/util-crypto' -const JOYSTREAM_SS58_PREFIX = 126 +import { CHAIN_PROPERTIES } from '@/app/constants/chain' -export const encodeAddress = (key: Parameters[0]) => encode(key, JOYSTREAM_SS58_PREFIX) +export const encodeAddress = (key: Parameters[0]) => encode(key, CHAIN_PROPERTIES.ss58Format) diff --git a/packages/ui/src/app/App.stories.tsx b/packages/ui/src/app/App.stories.tsx index b09248ba0a..ce62a5d97a 100644 --- a/packages/ui/src/app/App.stories.tsx +++ b/packages/ui/src/app/App.stories.tsx @@ -1,5 +1,6 @@ import { metadataToBytes } from '@joystream/js/utils' import { MembershipMetadata } from '@joystream/metadata-protobuf' +import { SubmittableExtrinsic } from '@polkadot/api/types' import { expect } from '@storybook/jest' import { Meta, StoryContext, StoryObj } from '@storybook/react' import { userEvent, waitFor, within } from '@storybook/testing-library' @@ -9,13 +10,19 @@ import { createGlobalStyle } from 'styled-components' import { Page, Screen } from '@/common/components/page/Page' import { Colors } from '@/common/constants' import { EMAIL_VERIFICATION_TOKEN_SEARCH_PARAM } from '@/memberships/constants' -import { GetMemberDocument } from '@/memberships/queries' +import { + GetMemberActionDetailsDocument, + GetMemberDocument, + GetMembersCountDocument, + GetMembersWithDetailsDocument, +} from '@/memberships/queries' import { ConfirmBackendEmailDocument, GetBackendMemberExistsDocument, RegisterBackendMemberDocument, } from '@/memberships/queries/__generated__/backend.generated' import { Membership, member } from '@/mocks/data/members' +import { validators } from '@/mocks/data/validators' import { Container, getButtonByText, joy, selectFromDropdown, withinModal } from '@/mocks/helpers' import { MocksParameters } from '@/mocks/providers' @@ -40,6 +47,8 @@ type Args = { onTransfer: jest.Mock onSubscribeEmail: jest.Mock onConfirmEmail: jest.Mock + onAddStakingAccount: jest.Mock + onConfirmStakingAccount: jest.Mock } type Story = StoryObj> @@ -47,6 +56,7 @@ type Story = StoryObj> const alice = member('alice') const bob = member('bob') const charlie = member('charlie') +const dave = member('dave') const NEW_MEMBER_DATA = { id: alice.id, // we set this to alice's ID so that after member is created, member with same ID can be found in MembershipContext @@ -73,6 +83,8 @@ export default { onTransfer: { action: 'BalanceTransfer' }, onSubscribeEmail: { action: 'SubscribeEmail' }, onConfirmEmail: { action: 'ConfirmEmail' }, + onAddStakingAccount: { action: 'AddStakingAccount' }, + onConfirmStakingAccount: { action: 'ConfirmStakingAccount' }, }, args: { @@ -99,7 +111,10 @@ export default { return { accounts: { active: args.isLoggedIn ? 'alice' : undefined, - list: args.hasMemberships || args.hasAccounts ? [account(alice), account(bob), account(charlie)] : [], + list: + args.hasMemberships || args.hasAccounts + ? [account(alice), account(bob), account(charlie), account(dave)] + : [], hasWallet: args.hasWallet, }, @@ -110,6 +125,15 @@ export default { members: { membershipPrice: joy(20) }, council: { stage: { stage: { isIdle: true }, changedAt: 123 } }, referendum: { stage: {} }, + staking: { + validators: { + entries: Object.entries(validators).map(([address, { commission }]) => [ + { args: [address] }, + { commission, blocked: false }, + ]), + }, + bonded: { multi: Object.keys(validators) }, + }, }, tx: { @@ -124,7 +148,27 @@ export default { event: 'MembershipBought', data: [NEW_MEMBER_DATA.id], onSend: args.onBuyMembership, - failure: parameters.txFailure, + failure: parameters.buyMembershipTxFailure, + }, + addStakingAccountCandidate: { + event: 'StakingAccountAdded', + data: [NEW_MEMBER_DATA.id], + onSend: args.onAddStakingAccount, + failure: parameters.addStakingAccountTxFailure, + }, + confirmStakingAccount: { + event: 'StakingAccountConfirmed', + data: [NEW_MEMBER_DATA.id], + onSend: args.onConfirmStakingAccount, + failure: parameters.confirmStakingAccountTxFailure, + }, + }, + utility: { + batch: { + event: 'TxBatch', + onSend: (signer: string, transactions: SubmittableExtrinsic<'rxjs'>[]) => + transactions.forEach((transaction) => transaction.signAndSend(signer)), + failure: parameters.batchTxFailure, }, }, }, @@ -140,6 +184,35 @@ export default { query: GetBackendMemberExistsDocument, data: { memberExist: args.hasRegisteredEmail }, }, + { + query: GetMemberDocument, + data: { membershipByUniqueInput: member('alice') }, + }, + { + query: GetMembersCountDocument, + data: { membershipsConnection: { totalCount: 0 } }, + }, + { + query: GetMembersWithDetailsDocument, + data: { memberships: [member('alice'), member('bob'), member('charlie'), member('dave')] }, + }, + { + query: GetMemberActionDetailsDocument, + data: { + stakeSlashedEventsConnection: { + totalCount: 2, + }, + terminatedLeaderEventsConnection: { + totalCount: 3, + }, + terminatedWorkerEventsConnection: { + totalCount: 4, + }, + memberInvitedEventsConnection: { + totalCount: 0, + }, + }, + }, ], mutations: [ { @@ -390,7 +463,7 @@ export const BuyMembershipHappy: Story = { expect(await modal.findByText('Success')) expect(modal.getByText(NEW_MEMBER_DATA.handle)) - expect(args.onBuyMembership).toHaveBeenCalledWith({ + expect(args.onBuyMembership).toHaveBeenCalledWith(bob.controllerAccount, { rootAccount: alice.controllerAccount, controllerAccount: bob.controllerAccount, handle: NEW_MEMBER_DATA.handle, @@ -435,7 +508,7 @@ export const BuyMembershipEmailSignup: Story = { expect(await modal.findByText('Success')) expect(modal.getByText(NEW_MEMBER_DATA.handle)) - expect(args.onBuyMembership).toHaveBeenCalledWith({ + expect(args.onBuyMembership).toHaveBeenCalledWith(bob.controllerAccount, { rootAccount: alice.controllerAccount, controllerAccount: bob.controllerAccount, handle: NEW_MEMBER_DATA.handle, @@ -479,7 +552,7 @@ export const BuyMembershipNotEnoughFund: Story = { export const BuyMembershipTxFailure: Story = { args: { hasMemberships: false, isLoggedIn: false }, - parameters: { txFailure: 'Some error message' }, + parameters: { buyMembershipTxFailure: 'Some error message' }, play: async ({ canvasElement }) => { const screen = within(canvasElement) @@ -494,11 +567,435 @@ export const BuyMembershipTxFailure: Story = { await userEvent.click(getButtonByText(modal, 'Sign and create a member')) - expect(await screen.findByText('Failure')) + expect(await modal.findByText('Failure')) expect(await modal.findByText('Some error message')) }, } +const fillMembershipFormValidatorAccounts = async (modal: Container, accounts: string[]) => { + await fillMembershipForm(modal) + const validatorCheckButton = modal.getAllByText('Yes')[1] + await userEvent.click(validatorCheckButton) + expect(await modal.findByText(/^If your validator account/)) + const validatorAccountsContainer = document.getElementsByClassName('validator-accounts')[0] + const addButton = modal.getByText('Add Validator Account') + for (let i = 0; i < accounts.length; i++) { + await userEvent.click(addButton) + } + const selectors = validatorAccountsContainer.querySelectorAll('input') + for (let i = 0; i < accounts.length; i++) { + await selectFromDropdown(modal, selectors[i], accounts[i]) + } +} + +export const BuyMembershipHappyBindOneValidatorHappy: 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 fillMembershipFormValidatorAccounts(modal, ['charlie']) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Create membership', async () => { + expect(modal.getByText('You intend to create a validator 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, 'Create membership')) + }) + + await step('Add validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Confirm validator account', async () => { + expect( + await modal.findByText('You are intending to confirm your validator account to be bound with your membership') + ) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Confirm')) + }) + + await step('Confirm', async () => { + expect(await modal.findByText('Success')) + expect(modal.getByText(NEW_MEMBER_DATA.handle)) + expect(args.onBuyMembership).toHaveBeenCalledWith(bob.controllerAccount, { + rootAccount: alice.controllerAccount, + controllerAccount: bob.controllerAccount, + handle: NEW_MEMBER_DATA.handle, + metadata: metadataToBytes(MembershipMetadata, { + name: NEW_MEMBER_DATA.metadata.name, + about: NEW_MEMBER_DATA.metadata.about, + avatarUri: NEW_MEMBER_DATA.metadata.avatar.avatarUri, + }), + invitingMemberId: undefined, + referrerId: undefined, + }) + expect(args.onAddStakingAccount).toHaveBeenCalledTimes(1) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(charlie.controllerAccount, NEW_MEMBER_DATA.id) + expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(1) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + bob.controllerAccount, + NEW_MEMBER_DATA.id, + charlie.controllerAccount + ) + + const doneButton = getButtonByText(modal, 'Done') + expect(doneButton).toBeEnabled() + userEvent.click(doneButton) + }) + }, +} + +export const BuyMembershipHappyAddTwoValidatorHappy: 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 fillMembershipFormValidatorAccounts(modal, ['charlie', 'dave']) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Create membership', async () => { + expect(modal.getByText('You intend to create a validator 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, 'Create membership')) + }) + + await step('Add first validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Add second validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'dave' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Confirm validator account', async () => { + expect( + await modal.findByText('You are intending to confirm your validator account to be bound with your membership') + ) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Confirm')) + }) + + await step('Confirm', async () => { + expect(await modal.findByText('Success')) + expect(modal.getByText(NEW_MEMBER_DATA.handle)) + expect(args.onBuyMembership).toHaveBeenCalledWith(bob.controllerAccount, { + rootAccount: alice.controllerAccount, + controllerAccount: bob.controllerAccount, + handle: NEW_MEMBER_DATA.handle, + metadata: metadataToBytes(MembershipMetadata, { + name: NEW_MEMBER_DATA.metadata.name, + about: NEW_MEMBER_DATA.metadata.about, + avatarUri: NEW_MEMBER_DATA.metadata.avatar.avatarUri, + }), + invitingMemberId: undefined, + referrerId: undefined, + }) + expect(args.onAddStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(charlie.controllerAccount, NEW_MEMBER_DATA.id) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(dave.controllerAccount, NEW_MEMBER_DATA.id) + expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + bob.controllerAccount, + NEW_MEMBER_DATA.id, + charlie.controllerAccount + ) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + bob.controllerAccount, + NEW_MEMBER_DATA.id, + dave.controllerAccount + ) + + const doneButton = getButtonByText(modal, 'Done') + expect(doneButton).toBeEnabled() + userEvent.click(doneButton) + }) + }, +} + +export const InvalidValidatorAccountInput: 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 validatorCheckButton = modal.getAllByText('Yes')[1] + await userEvent.click(validatorCheckButton) + const validatorAccountsContainer = document.getElementsByClassName('validator-accounts')[0] + const addButton = modal.getByText('Add Validator Account') + await userEvent.click(addButton) + const validatorAddressInputElement = validatorAccountsContainer.querySelectorAll('input')[0] + expect(validatorAddressInputElement).not.toBeNull() + await userEvent.paste(validatorAddressInputElement as HTMLElement, alice.controllerAccount) + + expect(modal.getByText('This account is neither a validator controller account nor a validator stash account.')) + const createButton = getButtonByText(modal, 'Create a Membership') + expect(createButton).toBeDisabled() + }, +} + +export const BuyMembershipWithValidatorAccountNotEnoughFunds: 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 fillMembershipFormValidatorAccounts(modal, ['charlie']) + 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, 'Create membership')).toBeDisabled() + }, +} + +export const BuyMembershipWithValidatorAccountFailure: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { buyMembershipTxFailure: 'Some error message' }, + + play: async ({ canvasElement }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await userEvent.click(getButtonByText(screen, 'Join Now')) + + await fillMembershipFormValidatorAccounts(modal, ['charlie']) + const createButton = getButtonByText(modal, 'Create a Membership') + await waitFor(() => expect(createButton).toBeEnabled()) + await userEvent.click(createButton) + + await userEvent.click(getButtonByText(modal, 'Create membership')) + + expect(await modal.findByText('Failure')) + expect(await modal.findByText('Some error message')) + }, +} + +export const BuyMembershipHappyAddOneValidatorFailure: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { addStakingAccountTxFailure: 'Some error message' }, + + play: async ({ 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 fillMembershipFormValidatorAccounts(modal, ['charlie']) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Create membership', async () => { + expect(modal.getByText('You intend to create a validator 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, 'Create membership')) + }) + + await step('Add Validator Account Tx Failure', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + + expect(await modal.findByText('Failure')) + expect(await modal.findByText('Some error message')) + }) + }, +} + +export const BuyMembershipAddValidatorAccHappyConfirmTxFailure: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { confirmStakingAccountTxFailure: 'Some error message' }, + + play: async ({ 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 fillMembershipFormValidatorAccounts(modal, ['charlie']) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Create membership', async () => { + expect(modal.getByText('You intend to create a validator 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, 'Create membership')) + }) + + await step('Add validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Confirm validator account', async () => { + expect( + await modal.findByText('You are intending to confirm your validator account to be bound with your membership') + ) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Confirm')) + + expect(await modal.findByText('Failure')) + expect(await modal.findByText('Some error message')) + }) + }, +} + +export const BuyMembershipAddTwoValidatorAccHappyConfirmTxFailure: Story = { + args: { hasMemberships: false, isLoggedIn: false }, + parameters: { batchTxFailure: 'Some error message' }, + + play: async ({ 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 fillMembershipFormValidatorAccounts(modal, ['charlie', 'dave']) + await waitFor(() => expect(createButton).toBeEnabled()) + }) + + await userEvent.click(createButton) + }) + + await step('Create membership', async () => { + expect(modal.getByText('You intend to create a validator 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, 'Create membership')) + }) + + await step('Add first validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Add second validator account', async () => { + expect(await modal.findByText('You are intending to bond your validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'dave' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Bond')) + }) + + await step('Confirm validator account', async () => { + expect( + await modal.findByText('You are intending to confirm your validator account to be bound with your membership') + ) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and Confirm')) + + expect(await modal.findByText('Failure')) + expect(await modal.findByText('Some error message')) + }) + }, +} // ---------------------------------------------------------------------------- // Test Email Subsciption Modal // ---------------------------------------------------------------------------- diff --git a/packages/ui/src/app/App.tsx b/packages/ui/src/app/App.tsx index 667dc0f45f..88a1bf82fd 100644 --- a/packages/ui/src/app/App.tsx +++ b/packages/ui/src/app/App.tsx @@ -18,6 +18,7 @@ import { parseEnv } from '@/common/utils/env' import { CouncilRoutes, ElectionRoutes } from '@/council/constants' import { ForumRoutes } from '@/forum/constant' import { ProposalsRoutes } from '@/proposals/constants/routes' +import { ValidatorsRoutes } from '@/validators/constants/routes' import { WorkingGroupsRoutes } from '@/working-groups/constants/routes' import { ErrorFallback } from './components/ErrorFallback' @@ -36,6 +37,7 @@ import { ProposalsModule } from './pages/Proposals/ProposalsModule' import { SettingsRoutes } from './pages/Settings/routes' import { SettingsModule } from './pages/Settings/SettingsModule' import { PrivacyPolicy, TermsOfService } from './pages/Terms' +import { ValidatorsModule } from './pages/Validators/ValidatorsModule' import { WorkingGroupsModule } from './pages/WorkingGroups/WorkingGroupsModule' import { Providers } from './Providers' @@ -65,6 +67,7 @@ export const App = () => { + diff --git a/packages/ui/src/app/Providers.tsx b/packages/ui/src/app/Providers.tsx index ded0ae48bf..5b2dcdc770 100644 --- a/packages/ui/src/app/Providers.tsx +++ b/packages/ui/src/app/Providers.tsx @@ -13,6 +13,7 @@ import { OnBoardingProvider } from '@/common/providers/onboarding/provider' import { ResponsiveProvider } from '@/common/providers/responsive/provider' import { TransactionStatusProvider } from '@/common/providers/transactionStatus/provider' import { MembershipContextProvider } from '@/memberships/providers/membership/provider' +import { ValidatorContextProvider } from '@/validators/providers/provider' import { BackendProvider } from './providers/backend/provider' import { GlobalStyle } from './providers/GlobalStyle' @@ -31,22 +32,24 @@ export const Providers = ({ children }: Props) => ( - - - - - - - - - {children} - - - - - - - + + + + + + + + + + {children} + + + + + + + + diff --git a/packages/ui/src/app/components/SideBar.tsx b/packages/ui/src/app/components/SideBar.tsx index 5d0b245a0b..e0e6953329 100644 --- a/packages/ui/src/app/components/SideBar.tsx +++ b/packages/ui/src/app/components/SideBar.tsx @@ -40,6 +40,7 @@ import { useElectionStage } from '@/council/hooks/useElectionStage' import { ForumRoutes } from '@/forum/constant' import { ProfileComponent } from '@/memberships/components/ProfileComponent' import { ProposalsRoutes } from '@/proposals/constants/routes' +import { ValidatorsRoutes } from '@/validators/constants/routes' import { WorkingGroupsRoutes } from '@/working-groups/constants' import { SettingsRoutes } from '../pages/Settings/routes' @@ -133,6 +134,11 @@ export const SideBarContent = () => { + + }> + Validators + + }> Settings diff --git a/packages/ui/src/app/constants/chain.ts b/packages/ui/src/app/constants/chain.ts new file mode 100644 index 0000000000..50f0be26af --- /dev/null +++ b/packages/ui/src/app/constants/chain.ts @@ -0,0 +1,5 @@ +export const CHAIN_PROPERTIES = { + ss58Format: 126, + tokenDecimals: [10], + tokenSymbol: ['JOY'], +} as const diff --git a/packages/ui/src/app/constants/currency.ts b/packages/ui/src/app/constants/currency.ts index 34bf9f1c4c..00a2759d76 100644 --- a/packages/ui/src/app/constants/currency.ts +++ b/packages/ui/src/app/constants/currency.ts @@ -1,5 +1,7 @@ +import { CHAIN_PROPERTIES } from './chain' + export const CurrencyName = { - integerValue: 'JOY', + integerValue: CHAIN_PROPERTIES.tokenSymbol[0], } as const export { ED, BN_ZERO, JOY_DECIMAL_PLACES } from '@/common/constants' diff --git a/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx b/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx new file mode 100644 index 0000000000..b04221ce88 --- /dev/null +++ b/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx @@ -0,0 +1,603 @@ +import { SubmittableExtrinsic } from '@polkadot/api/types' +import { expect } from '@storybook/jest' +import { Meta, StoryContext, StoryObj } from '@storybook/react' +import { userEvent, waitFor, within } from '@storybook/testing-library' +import { FC } from 'react' + +import { + GetMemberActionDetailsDocument, + GetMemberDocument, + GetMembersCountDocument, + GetMembersWithDetailsDocument, +} from '@/memberships/queries' +import { Membership, member } from '@/mocks/data/members' +import { validators } from '@/mocks/data/validators' +import { Container, getButtonByText, joy, selectFromDropdown, withinModal } from '@/mocks/helpers' +import { MocksParameters } from '@/mocks/providers' + +import { MyMemberships } from './MyMemberships' + +const alice = member('alice') +const bob = member('bob') +const charlie = member('charlie') +const dave = member('dave') +const eve = member('eve') + +const NEW_MEMBER_DATA = { + id: alice.id, + metadata: { + name: 'BobbyBob', + about: 'Lorem ipsum...', + avatar: { avatarUri: 'https://api.dicebear.com/6.x/bottts-neutral/svg?seed=bob' }, + }, +} + +type Args = { + onUpdateProfile: jest.Mock + onUpdateAccounts: jest.Mock + onAddStakingAccount: jest.Mock + onConfirmStakingAccount: jest.Mock + onRemoveStakingAccount: jest.Mock +} + +export default { + title: 'Pages/MyProfile/MyMemberships', + component: MyMemberships, + + argTypes: { + onUpdateProfile: { action: 'UpdateProfile' }, + onUpdateAccounts: { action: 'UpdateAccounts' }, + onAddStakingAccount: { action: 'AddStakingAccount' }, + onConfirmStakingAccount: { action: 'ConfirmStakingAccount' }, + onRemoveStakingAccount: { action: 'RemoveStakingAccount' }, + }, + + parameters: { + totalBalance: 100, + router: { + href: '/profile/memberships', + }, + mocks: ({ args, parameters }: StoryContext): MocksParameters => { + const account = (member: Membership) => ({ + balances: parameters.totalBalance, + ...{ member }, + }) + return { + accounts: { + active: 'alice', + list: [account(alice), account(bob), account(charlie), account(dave), account(eve)], + hasWallet: true, + }, + chain: { + query: { + members: { membershipPrice: joy(20) }, + membershipWorkingGroup: { budget: joy(166666_66) }, + staking: { + validators: { + entries: Object.entries(validators).map(([address, { commission }]) => [ + { args: [address] }, + { commission, blocked: false }, + ]), + }, + bonded: { multi: Object.keys(validators) }, + }, + }, + derive: { + balances: { + all: { + freeBalance: 1000, + reservedBalance: 1000, + availableBalance: 1000, + lockedBalance: 1000, + lockedBreakdown: [], + vestingLocked: 1000, + isVesting: false, + vestedBalance: joy(1666_66), + vestedClaimable: joy(1666_66), + vesting: [], + vestingTotal: joy(1666_66), + additional: [], + namedReserves: [[]], + }, + }, + }, + + tx: { + members: { + updateProfile: { + event: 'MembershipBought', + data: [NEW_MEMBER_DATA.id], + onSend: args.onUpdateProfile, + failure: parameters.updateProfileTxFailure, + }, + updateAccounts: { + event: 'MembershipBought', + data: [NEW_MEMBER_DATA.id], + onSend: args.onUpdateAccounts, + failure: parameters.updateAccountsTxFailure, + }, + addStakingAccountCandidate: { + event: 'StakingAccountAdded', + data: [NEW_MEMBER_DATA.id], + onSend: args.onAddStakingAccount, + failure: parameters.addStakingAccountTxFailure, + }, + confirmStakingAccount: { + event: 'StakingAccountConfirmed', + data: [NEW_MEMBER_DATA.id], + onSend: args.onConfirmStakingAccount, + failure: parameters.confirmStakingAccountTxFailure, + }, + removeStakingAccount: { + event: 'StakingAccountRemoved', + data: [NEW_MEMBER_DATA.id], + onSend: args.onRemoveStakingAccount, + failure: parameters.removeStakingAccountTxFailure, + }, + }, + utility: { + batch: { + event: 'TxBatch', + onSend: (signer: string, transactions: SubmittableExtrinsic<'rxjs'>[]) => + transactions.forEach((transaction) => transaction.signAndSend(signer)), + failure: parameters.batchTxFailure, + }, + }, + }, + }, + + gql: { + queries: [ + { + query: GetMemberDocument, + data: { membershipByUniqueInput: member('alice') }, + }, + { + query: GetMembersCountDocument, + data: { membershipsConnection: { totalCount: 3 } }, + }, + { + query: GetMembersWithDetailsDocument, + data: { memberships: [member('alice'), member('bob'), member('charlie'), member('dave'), member('eve')] }, + }, + { + query: GetMemberActionDetailsDocument, + data: { + stakeSlashedEventsConnection: { + totalCount: 2, + }, + terminatedLeaderEventsConnection: { + totalCount: 3, + }, + terminatedWorkerEventsConnection: { + totalCount: 4, + }, + memberInvitedEventsConnection: { + totalCount: 0, + }, + }, + }, + ], + }, + } + }, + }, +} satisfies Meta + +type Story = StoryObj> +export const Default: Story = {} + +const fillMembershipForm = async (modal: Container) => { + await selectFromDropdown(modal, modal.getByText('Root account', { selector: 'label' }), 'bob') + await selectFromDropdown(modal, modal.getByText('Controller account', { selector: 'label' }), 'charlie') + await userEvent.type(modal.getByLabelText('Member Name'), NEW_MEMBER_DATA.metadata.name) + await userEvent.type(modal.getByLabelText('About member'), NEW_MEMBER_DATA.metadata.about) + await userEvent.type(modal.getByLabelText('Member Avatar'), NEW_MEMBER_DATA.metadata.avatar.avatarUri) +} + +export const UpdateMembershipHappy: Story = { + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await waitFor(() => expect(screen.getByText('alice'))) + const editButton = document.getElementsByClassName('edit-button')[0] + await userEvent.click(editButton) + + await step('Form', async () => { + const saveButton = getButtonByText(modal, 'Save changes') + expect(saveButton).toBeDisabled() + await fillMembershipForm(modal) + expect(saveButton).toBeEnabled() + await userEvent.click(saveButton) + }) + + await step('Sign', async () => { + expect(modal.getByText('Authorize transaction')) + expect(modal.getByText('You intend to update your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'alice' })) + + await userEvent.click(getButtonByText(modal, 'Sign and update a member')) + }) + + await step('Confirm', async () => { + expect(await modal.findByText('Success')) + expect(modal.getByText('alice')) + expect(args.onUpdateAccounts).toHaveBeenCalledTimes(1) + expect(args.onUpdateProfile).toHaveBeenCalledTimes(1) + + const viewProfileButton = getButtonByText(modal, 'View my profile') + expect(viewProfileButton).toBeEnabled() + userEvent.click(viewProfileButton) + expect(modal.getByText('alice')) + }) + }, +} + +export const UpdateMembershipFailure: Story = { + parameters: { batchTxFailure: 'Some error message' }, + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await waitFor(() => expect(screen.getByText('alice'))) + const editButton = document.getElementsByClassName('edit-button')[0] + await userEvent.click(editButton) + + await step('Form', async () => { + const saveButton = getButtonByText(modal, 'Save changes') + expect(saveButton).toBeDisabled() + await fillMembershipForm(modal) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + }) + + await step('Sign', async () => { + expect(modal.getByText('Authorize transaction')) + await userEvent.click(getButtonByText(modal, 'Sign and update a member')) + }) + + await step('Confirm', async () => { + expect(await modal.findByText('Failure')) + expect(await modal.findByText('There was a problem updating membership.')) + }) + }, +} + +const addValidatorAccounts = async (modal: Container, accounts: string[]) => { + const validatorAccountsContainer = document.getElementsByClassName('validator-accounts')[0] + const addButton = modal.getByText('Add Validator Account') + for (let i = 0; i < accounts.length; i++) { + await userEvent.click(addButton) + } + const selectors = validatorAccountsContainer.querySelectorAll('input') + for (let i = 0; i < accounts.length; i++) { + await selectFromDropdown(modal, selectors[selectors.length - (accounts.length - i)], accounts[i]) + } +} + +const removeValidatorAccounts = async (accounts: string[]) => { + const validatorAccountsContainer = within(document.getElementsByClassName('validator-accounts')[0] as HTMLElement) + const nthParentElement = (element: HTMLElement, n: number) => { + let parent = element as HTMLElement | null + for (let i = 0; i < n; i++) { + parent = parent?.parentElement ?? null + } + return parent + } + for (const account of accounts) { + const removeButton = nthParentElement(validatorAccountsContainer.getByText(account), 8)?.querySelector( + '.remove-button' + ) + if (!removeButton) throw `Not found the '${account}' account to removed.` + await userEvent.click(removeButton) + } +} + +export const UpdateValidatorAccountsHappy: Story = { + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await waitFor(() => expect(screen.getByText('alice'))) + const editButton = document.getElementsByClassName('edit-button')[0] + await userEvent.click(editButton) + + await step('Form', async () => { + const saveButton = getButtonByText(modal, 'Save changes') + expect(saveButton).toBeDisabled() + await fillMembershipForm(modal) + await removeValidatorAccounts(['bob', 'charlie']) + await addValidatorAccounts(modal, ['dave', 'eve']) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + }) + + await step('Remove Validator Account: bob', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByText('You intend to remove the validator account from your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and unbond')) + }) + + await step('Remove Validator Account: charlie', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and unbond')) + }) + + await step('Add Validator Account: dave', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByText('You intend to to bond new validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'dave' })) + + await userEvent.click(getButtonByText(modal, 'Sign and bond')) + }) + + await step('Add Validator Account: eve', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByRole('heading', { name: 'eve' })) + + await userEvent.click(getButtonByText(modal, 'Sign and bond')) + }) + + await step('Confirm validator accounts', async () => { + expect(await modal.findByText('You intend to confirm your validator account to be bound with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'alice' })) + + await userEvent.click(getButtonByText(modal, 'Sign and confirm')) + }) + + await step('Update Membership', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByText('You intend to update your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'alice' })) + + await userEvent.click(getButtonByText(modal, 'Sign and update a member')) + }) + + await step('Confirm', async () => { + expect(await modal.findByText('Success')) + expect(modal.getByText('alice')) + expect(args.onRemoveStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onRemoveStakingAccount).toBeCalledWith(bob.controllerAccount, alice.id) + expect(args.onRemoveStakingAccount).toBeCalledWith(charlie.controllerAccount, alice.id) + expect(args.onAddStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(dave.controllerAccount, alice.id) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(eve.controllerAccount, alice.id) + expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + alice.controllerAccount, + alice.id, + dave.controllerAccount + ) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + alice.controllerAccount, + alice.id, + eve.controllerAccount + ) + }) + }, +} + +export const UnbondValidatorAccountsHappy: Story = { + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await waitFor(() => expect(screen.getByText('alice'))) + const editButton = document.getElementsByClassName('edit-button')[0] + await userEvent.click(editButton) + + await step('Form', async () => { + const saveButton = getButtonByText(modal, 'Save changes') + expect(saveButton).toBeDisabled() + await waitFor(() => removeValidatorAccounts(['bob', 'charlie'])) + const validatorCheckButton = modal.getAllByText('No')[0] + await userEvent.click(validatorCheckButton) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + }) + + await step('Remove Validator Account: bob', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByText('You intend to remove the validator account from your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and unbond')) + }) + + await step('Remove Validator Account: charlie', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByRole('heading', { name: 'charlie' })) + + await userEvent.click(getButtonByText(modal, 'Sign and unbond')) + }) + + await step('Confirm', async () => { + expect(await modal.findByText('Success')) + expect(modal.getByText('alice')) + expect(args.onRemoveStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onRemoveStakingAccount).toBeCalledWith(bob.controllerAccount, alice.id) + expect(args.onRemoveStakingAccount).toBeCalledWith(charlie.controllerAccount, alice.id) + }) + }, +} + +export const UnbondValidatorAccountFailure: Story = { + parameters: { removeStakingAccountTxFailure: 'Some error message' }, + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await waitFor(() => expect(screen.getByText('alice'))) + const editButton = document.getElementsByClassName('edit-button')[0] + await userEvent.click(editButton) + + await step('Form', async () => { + const saveButton = getButtonByText(modal, 'Save changes') + expect(saveButton).toBeDisabled() + await waitFor(() => removeValidatorAccounts(['bob'])) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + }) + + await step('Remove Validator Account Failure', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByText('You intend to remove the validator account from your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'bob' })) + + await userEvent.click(getButtonByText(modal, 'Sign and unbond')) + expect(await modal.findByText('Failure')) + expect(await modal.findByText('There was a problem updating membership.')) + }) + }, +} + +export const BondValidatorAccountsHappy: Story = { + play: async ({ args, canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await waitFor(() => expect(screen.getByText('alice'))) + const editButton = document.getElementsByClassName('edit-button')[0] + await userEvent.click(editButton) + + await step('Form', async () => { + const saveButton = getButtonByText(modal, 'Save changes') + expect(saveButton).toBeDisabled() + await waitFor(() => addValidatorAccounts(modal, ['dave', 'eve'])) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + }) + + await step('Add Validator Account: dave', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByText('You intend to to bond new validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'dave' })) + + await userEvent.click(getButtonByText(modal, 'Sign and bond')) + }) + + await step('Add Validator Account: eve', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByRole('heading', { name: 'eve' })) + + await userEvent.click(getButtonByText(modal, 'Sign and bond')) + }) + + await step('Confirm validator accounts', async () => { + expect(await modal.findByText('You intend to confirm your validator account to be bound with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'alice' })) + + await userEvent.click(getButtonByText(modal, 'Sign and confirm')) + }) + + await step('Confirm', async () => { + expect(await modal.findByText('Success')) + expect(modal.getByText('alice')) + expect(args.onAddStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(dave.controllerAccount, alice.id) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(eve.controllerAccount, alice.id) + expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + alice.controllerAccount, + alice.id, + dave.controllerAccount + ) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + alice.controllerAccount, + alice.id, + eve.controllerAccount + ) + }) + }, +} + +export const BondValidatorAccountFailure: Story = { + parameters: { addStakingAccountTxFailure: 'Some error message' }, + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await waitFor(() => expect(screen.getByText('alice'))) + const editButton = document.getElementsByClassName('edit-button')[0] + await userEvent.click(editButton) + + await step('Form', async () => { + const saveButton = getButtonByText(modal, 'Save changes') + expect(saveButton).toBeDisabled() + await waitFor(() => addValidatorAccounts(modal, ['dave'])) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + }) + + await step('Add Validator Account: dave', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByText('You intend to to bond new validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'dave' })) + + await userEvent.click(getButtonByText(modal, 'Sign and bond')) + expect(await modal.findByText('Failure')) + expect(await modal.findByText('There was a problem updating membership.')) + }) + }, +} + +export const UnbondValidatorAccountHappyConfirmFailure: Story = { + parameters: { batchTxFailure: 'Some error message' }, + play: async ({ canvasElement, step }) => { + const screen = within(canvasElement) + const modal = withinModal(canvasElement) + + await waitFor(() => expect(screen.getByText('alice'))) + const editButton = document.getElementsByClassName('edit-button')[0] + await userEvent.click(editButton) + + await step('Form', async () => { + const saveButton = getButtonByText(modal, 'Save changes') + expect(saveButton).toBeDisabled() + await waitFor(() => addValidatorAccounts(modal, ['dave', 'eve'])) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + }) + + await step('Add Validator Account: dave', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByText('You intend to to bond new validator account with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'dave' })) + + await userEvent.click(getButtonByText(modal, 'Sign and bond')) + }) + + await step('Add Validator Account: eve', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByRole('heading', { name: 'eve' })) + + await userEvent.click(getButtonByText(modal, 'Sign and bond')) + }) + + await step('Confirm validator accounts', async () => { + expect(await modal.findByText('You intend to confirm your validator account to be bound with your membership.')) + expect(modal.getByText('Transaction fee:')?.nextSibling?.textContent).toBe('5') + expect(modal.getByRole('heading', { name: 'alice' })) + + await userEvent.click(getButtonByText(modal, 'Sign and confirm')) + expect(await modal.findByText('Failure')) + expect(await modal.findByText('There was a problem updating membership.')) + }) + }, +} diff --git a/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx index b9933de496..fe4be301b1 100644 --- a/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx +++ b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx @@ -463,7 +463,7 @@ export const AddNewProposalHappy: Story = { }) await step('Sign Create Proposal transaction', async () => { - expect(await modal.findByText('You intend to create a proposal.')) + expect(await modal.findByText('You intend to create a proposal.', undefined, { timeout: 2000 })) await userEvent.click(modal.getByText('Sign transaction and Create')) }) @@ -474,11 +474,16 @@ export const AddNewProposalHappy: Story = { }) step('Transaction parameters', () => { - expect(args.onAddStakingAccountCandidate).toHaveBeenCalledWith(alice.id) + expect(args.onAddStakingAccountCandidate).toHaveBeenCalledWith(alice.controllerAccount, alice.id) - expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, alice.controllerAccount) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + alice.controllerAccount, + alice.id, + alice.controllerAccount + ) - const [generalParameters] = args.onCreateProposal.mock.calls.at(-1) + const [signer, generalParameters] = args.onCreateProposal.mock.calls.at(-1) + expect(signer).toBe(alice.controllerAccount) expect(generalParameters).toEqual({ memberId: alice.id, title: PROPOSAL_DATA.title, @@ -488,8 +493,9 @@ export const AddNewProposalHappy: Story = { }) const changeModeTxParams = args.onChangeThreadMode.mock.calls.at(-1) - expect(changeModeTxParams.length).toBe(3) - const [memberId, threadId, mode] = changeModeTxParams + expect(changeModeTxParams.length).toBe(4) + const [changeModeSigner, memberId, threadId, mode] = changeModeTxParams + expect(changeModeSigner).toBe(alice.controllerAccount) expect(memberId).toBe(alice.id) expect(typeof threadId).toBe('number') expect(mode.toJSON()).toEqual({ closed: [] }) @@ -747,7 +753,7 @@ export const SpecificParametersSignal: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toHuman()).toEqual({ Signal: 'Lorem ipsum...' }) }) }), @@ -775,9 +781,9 @@ export const SpecificParametersFundingRequest: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ - fundingRequest: [{ account: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', amount: 100_0000000000 }], + fundingRequest: [{ account: alice.controllerAccount, amount: 100_0000000000 }], }) }) }), @@ -785,9 +791,9 @@ export const SpecificParametersFundingRequest: Story = { export const SpecificParametersMultipleFundingRequest: Story = { play: specificParametersTest('Funding Request', async ({ args, createProposal, modal, step }) => { - const aliceAddress = '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY' - const bobAddress = '5FHneW46xGXgs5mUiveU4sbTyGBzmstUspZC92UhjJM694ty' - const charlieAddess = member('charlie').controllerAccount + const aliceAddress = alice.controllerAccount + const bobAddress = member('bob').controllerAccount + const charlieAddress = member('charlie').controllerAccount await createProposal(async () => { const nextButton = getButtonByText(modal, 'Create proposal') @@ -838,7 +844,7 @@ export const SpecificParametersMultipleFundingRequest: Story = { // Max Allowed Accounts error await userEvent.clear(csvField) - await userEvent.type(csvField, `${aliceAddress},400\n${bobAddress},500\n${charlieAddess},500`) + await userEvent.type(csvField, `${aliceAddress},400\n${bobAddress},500\n${charlieAddress},500`) expect(await modal.findByText(/Please preview and validate the inputs to proceed/)) expect(nextButton).toBeDisabled() await waitFor(() => expect(previewButton).toBeEnabled()) @@ -865,7 +871,7 @@ export const SpecificParametersMultipleFundingRequest: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ fundingRequest: [ { account: aliceAddress, amount: 500_0000000000 }, @@ -902,7 +908,7 @@ export const SpecificParametersSetReferralCut: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ setReferralCut: 100 }) }) }), @@ -950,7 +956,7 @@ export const SpecificParametersDecreaseWorkingGroupLeadStake: Story = { step('Transaction parameters', () => { const leaderId = 10 // Set on the mock QN query - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ decreaseWorkingGroupLeadStake: [leaderId, 500_0000000000, 'Forum'], }) @@ -988,7 +994,7 @@ export const SpecificParametersTerminateWorkingGroupLead: Story = { step('Transaction parameters', () => { const leaderId = 10 // Set on the mock QN query - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ terminateWorkingGroupLead: { workerId: leaderId, @@ -1053,7 +1059,7 @@ export const SpecificParametersCreateWorkingGroupLeadOpening: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) const { description, ...data } = specificParameters.asCreateWorkingGroupLeadOpening.toJSON() expect(data).toEqual({ @@ -1119,7 +1125,7 @@ export const SpecificParametersSetWorkingGroupLeadReward: Story = { step('Transaction parameters', () => { const leaderId = 10 // Set on the mock QN query - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ setWorkingGroupLeadReward: [leaderId, 10_0000000000, 'Forum'], }) @@ -1155,7 +1161,7 @@ export const SpecificParametersSetMaxValidatorCount: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ setMaxValidatorCount: 10 }) }) }), @@ -1175,7 +1181,7 @@ export const SpecificParametersCancelWorkingGroupLeadOpening: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ cancelWorkingGroupLeadOpening: [12, 'Storage'] }) }) }), @@ -1208,7 +1214,7 @@ export const SpecificParametersSetCouncilBudgetIncrement: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ setCouncilBudgetIncrement: 500_0000000000 }) }) }), @@ -1235,7 +1241,7 @@ export const SpecificParametersSetCouncilorReward: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ setCouncilorReward: 10_0000000000 }) }) }), @@ -1272,7 +1278,7 @@ export const SpecificParametersSetMembershipLeadInvitationQuota: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ setMembershipLeadInvitationQuota: 3 }) }) } @@ -1310,7 +1316,7 @@ export const SpecificParametersFillWorkingGroupLeadOpening: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ fillWorkingGroupLeadOpening: { applicationId: 15, @@ -1351,7 +1357,7 @@ export const SpecificParametersSetInitialInvitationCount: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ setInitialInvitationCount: 7 }) }) }), @@ -1381,7 +1387,7 @@ export const SpecificParametersSetInitialInvitationBalance: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ setInitialInvitationBalance: 7_0000000000 }) }) }), @@ -1406,7 +1412,7 @@ export const SpecificParametersSetMembershipPrice: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ setMembershipPrice: 8_0000000000 }) }) }), @@ -1465,7 +1471,7 @@ export const SpecificParametersUpdateWorkingGroupBudget: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ updateWorkingGroupBudget: [99_0000000000, 'Forum', 'Negative'], }) @@ -1500,7 +1506,7 @@ export const SpecificParametersRuntimeUpgrade: Story = { }) step('Transaction parameters', () => { - const [, specificParameters] = args.onCreateProposal.mock.calls.at(-1) + const [, , specificParameters] = args.onCreateProposal.mock.calls.at(-1) expect(specificParameters.toJSON()).toEqual({ runtimeUpgrade: '0x' }) }) }), diff --git a/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx b/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx index 0ba815ce41..a08a44a29a 100644 --- a/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx +++ b/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx @@ -475,7 +475,13 @@ export const TestVoteHappy: Story = { ) expect(within(confirmText).getByText('Approve')) - expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Approve', 'Some rationale') + expect(onVote).toHaveBeenLastCalledWith( + activeMember.controllerAccount, + activeMember.id, + PROPOSAL_DATA.id, + 'Approve', + 'Some rationale' + ) await userEvent.click(modal.getByText('Back to proposals')) }) @@ -509,7 +515,13 @@ export const TestVoteHappy: Story = { ) expect(within(confirmText).getByText('Reject')) - expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Reject', 'Some rationale') + expect(onVote).toHaveBeenLastCalledWith( + activeMember.controllerAccount, + activeMember.id, + PROPOSAL_DATA.id, + 'Reject', + 'Some rationale' + ) await userEvent.click(modal.getByText('Back to proposals')) }) @@ -545,7 +557,13 @@ export const TestVoteHappy: Story = { ) expect(within(confirmText).getByText('Slash')) - expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Slash', 'Some rationale') + expect(onVote).toHaveBeenLastCalledWith( + activeMember.controllerAccount, + activeMember.id, + PROPOSAL_DATA.id, + 'Slash', + 'Some rationale' + ) await userEvent.click(modal.getByText('Back to proposals')) }) @@ -573,7 +591,13 @@ export const TestVoteHappy: Story = { ) expect(within(confirmText).getByText('Abstain')) - expect(onVote).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id, 'Abstain', 'Some rationale') + expect(onVote).toHaveBeenLastCalledWith( + activeMember.controllerAccount, + activeMember.id, + PROPOSAL_DATA.id, + 'Abstain', + 'Some rationale' + ) }) }) }, @@ -640,7 +664,7 @@ export const TestCancelProposalHappy: Story = { await step('Confirm', async () => { expect(await modal.findByText('Your propsal has been cancelled.')) - expect(onCancel).toHaveBeenLastCalledWith(activeMember.id, PROPOSAL_DATA.id) + expect(onCancel).toHaveBeenLastCalledWith(activeMember.controllerAccount, activeMember.id, PROPOSAL_DATA.id) }) }) }, diff --git a/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx b/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx index e32ce40fb8..ba5c1a66d6 100644 --- a/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx +++ b/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx @@ -1,10 +1,11 @@ import { expect } from '@storybook/jest' import { Meta, StoryObj } from '@storybook/react' import { userEvent, waitFor, within } from '@storybook/testing-library' +import { of } from 'rxjs' -import { Address } from '@/common/types' import { GetMembersWithDetailsDocument } from '@/memberships/queries' import { member } from '@/mocks/data/members' +import { validators } from '@/mocks/data/validators' import { joy, selectFromDropdown } from '@/mocks/helpers' import { MocksParameters } from '@/mocks/providers' @@ -12,6 +13,37 @@ import { ValidatorList } from './ValidatorList' type Args = object +const activeEra = { + index: 700, + start: Date.now() - 5400000, + points: 18_000, + stakers: (address: keyof typeof validators) => { + const validator = validators[address] + const nominators = 'nominators' in validator ? validator.nominators : [] + const others = Object.entries(nominators).map(([who, data]) => ({ who, value: data.stake })) + return { total: validator.totalStake, own: validator.ownStake, others } + }, +} + +const mocksValidatorsPoints = (...validatorIndexes: number[]) => { + const addresses = Object.keys(validators) + return Object.fromEntries(validatorIndexes.map((index) => [addresses[index], Math.floor(Math.random() * 800 + 200)])) +} +const pastEras = { + 688: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(0, 1, 2, 3) }, + 689: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(2, 3, 4) }, + 690: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(1, 2, 3) }, + 691: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(0, 1, 3, 4) }, + 692: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(1, 3, 4) }, + 693: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(0, 1, 3, 4) }, + 694: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(1, 3, 4) }, + 695: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints() }, + 696: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(1) }, + 697: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(1, 2, 4) }, + 698: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(0, 1, 2, 3, 4) }, + 699: { eraReward: joy(0.123456), eraPoints: 18_000, validators: mocksValidatorsPoints(1, 2, 3, 4) }, +} + export default { title: 'Pages/Validators/ValidatorList', component: ValidatorList, @@ -22,411 +54,54 @@ export default { chain: { derive: { staking: { - erasRewards: [ - { era: 688, eraReward: joy(0.123456) }, - { era: 689, eraReward: joy(0.123456) }, - { era: 690, eraReward: joy(0.123456) }, - { era: 691, eraReward: joy(0.123456) }, - { era: 692, eraReward: joy(0.123456) }, - { era: 693, eraReward: joy(0.123456) }, - { era: 694, eraReward: joy(0.123456) }, - { era: 695, eraReward: joy(0.123456) }, - { era: 696, eraReward: joy(0.123456) }, - { era: 697, eraReward: joy(0.123456) }, - { era: 698, eraReward: joy(0.123456) }, - { era: 699, eraReward: joy(0.123456) }, - { era: 700, eraReward: joy(0.123456) }, - ], - stakerRewards: (address: Address) => { - const validatorRewards = [ - { - address: 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', - rewards: [{ eraReward: joy(0.5) }], - }, - { - address: 'j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW', - rewards: [{ eraReward: joy(0.5) }], - }, - { - address: 'j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP', - rewards: [{ eraReward: joy(0.9) }], - }, - { - address: 'j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz', - rewards: [{ eraReward: joy(0.1) }], - }, - { - address: 'j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa', - rewards: [{ eraReward: joy(0.1) }], - }, - { - address: 'j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN', - rewards: [{ eraReward: joy(0.8) }], - }, - { - address: 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', - rewards: [{ eraReward: joy(0.4) }], - }, - { - address: 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', - rewards: [{ eraReward: joy(0.6) }], - }, - { - address: 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', - rewards: [{ eraReward: joy(0.7) }], - }, - { - address: 'j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt', - rewards: [{ eraReward: joy(0.7) }], - }, - { - address: 'j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM', - rewards: [{ eraReward: joy(0.7) }], - }, - ] - return validatorRewards.find((validatorReward) => validatorReward.address === address)?.rewards ?? [] - }, + erasRewards: Object.entries(pastEras).map(([era, data]) => ({ era, eraReward: data.eraReward })), + erasPoints: Object.entries(pastEras).map(([era, data]) => ({ + era, + eraPoints: data.eraPoints, + validators: data.validators, + })), }, }, + query: { balances: { totalIssuance: joy(1000000), }, - timestamp: { now: Date.now() }, + session: { - validators: [ - 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', - 'j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW', - 'j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP', - 'j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz', - 'j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa', - 'j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN', - 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', - 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', - 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', - ], + validators: of( + Object.entries(validators).flatMap(([address, data]) => ('nominators' in data ? address : [])) + ), }, + staking: { - activeEra: { - index: 700, - start: Date.now() - 5400000, + validators: { + entries: Object.entries(validators).map(([address, { commission }]) => [ + { args: [address] }, + { commission, blocked: false }, + ]), }, - bonded: { - multi: [ - 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', - 'j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW', - 'j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP', - 'j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz', - 'j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa', - 'j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN', - 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', - 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', - 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', - 'j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt', - 'j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM', - ], + bonded: { multi: Object.keys(validators) }, + + activeEra: { + index: activeEra.index, + start: activeEra.start, }, - counterForValidators: 12, + erasStakers: (_: any, address: keyof typeof validators) => activeEra.stakers(address), counterForNominators: 20, - erasRewardPoints: { - total: 18000, - entries: [ - [ - { args: [1090] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: Math.floor(Math.random() * 800 + 200), - j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN: Math.floor(Math.random() * 800 + 200), - j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [1000] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: Math.floor(Math.random() * 800 + 200), - j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [1040] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: Math.floor(Math.random() * 800 + 200), - j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN: Math.floor(Math.random() * 800 + 200), - j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [1100] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: Math.floor(Math.random() * 800 + 200), - j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN: Math.floor(Math.random() * 800 + 200), - j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [1030] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: Math.floor(Math.random() * 800 + 200), - j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN: Math.floor(Math.random() * 800 + 200), - j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [1020] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: Math.floor(Math.random() * 800 + 200), - j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN: Math.floor(Math.random() * 800 + 200), - j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [1060] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [1050] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [1070] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: Math.floor(Math.random() * 800 + 200), - j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN: Math.floor(Math.random() * 800 + 200), - j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [990] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: Math.floor(Math.random() * 800 + 200), - j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN: Math.floor(Math.random() * 800 + 200), - j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [1080] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: Math.floor(Math.random() * 800 + 200), - j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN: Math.floor(Math.random() * 800 + 200), - j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - [ - { args: [1010] }, - { - total: 18000, - individual: { - j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: Math.floor(Math.random() * 800 + 200), - j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: Math.floor(Math.random() * 800 + 200), - j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: Math.floor(Math.random() * 800 + 200), - j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: Math.floor(Math.random() * 800 + 200), - j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa: Math.floor(Math.random() * 800 + 200), - j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN: Math.floor(Math.random() * 800 + 200), - j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: Math.floor(Math.random() * 800 + 200), - j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: Math.floor(Math.random() * 800 + 200), - j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt: Math.floor(Math.random() * 800 + 200), - j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM: Math.floor(Math.random() * 800 + 200), - }, - }, - ], - ], - }, - erasValidatorReward: joy(0.123456), - erasStakers: { - total: joy(400), - own: joy(0.0001), - others: [ - { who: 'j4WGdFxqTkyAgzJiTbEBeRseP12dPEvJgf2Wy9qkPa68XSP55', value: joy(0.2) }, - { who: 'j4UQEfPFnKwGuHytxs9YEouLnhnSNkPDgNm9tKeB7an3dRaiy', value: joy(0.2) }, - { who: 'j4WwTZ3fnkoXJw3D1vGVyymjaiLxM78TGyAAX41JRH8Kx6T2u', value: joy(0.2) }, - { who: 'j4WqZwj6KjB4DbxknxyJB1ZkeVrPRGmg6DUGw2YkuAy7jUERg', value: joy(0.2) }, - { who: 'j4WwTZ3fnkoXJw3D1vGVyymjaiLxM78TGyAAX41JRH8Kx6T2u', value: joy(0.2) }, - { who: 'j4Wo9377XBAvhmB35J4TkpJUHnUKmyccXhGtHCVvi6pPr9so8', value: joy(0.2) }, - { who: 'j4WwTZ3fnkoXJw3D1vGVyymjaiLxM78TGyAAX41JRH8Kx6T2u', value: joy(0.2) }, - { who: 'j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM', value: joy(0.2) }, - { who: 'j4WwTZ3fnkoXJw3D1vGVyymjaiLxM78TGyAAX41JRH8Kx6T2u', value: joy(0.2) }, - { who: 'j4T3XgRMUaZZL6GsMk6RXfBcjuMWxfSLnoATYkBTHh7xyjmoH', value: joy(0.2) }, - { who: 'j4W2bw7ggG69e9TZ77RP9mjem1GrbPwpbKYK7WdZiym77yzMJ', value: joy(0.2) }, - { who: 'j4UzoJUhDGpnsCWrmx9ojofwaT8KHz3azp8C1S49MSN6rYjim', value: joy(0.2) }, - { who: 'j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt', value: joy(0.2) }, - { who: 'j4SgrgDrzzGyfrxPe4ZgaKfByKyLo5SdsUXNfHzZJPh5R6f8q', value: joy(0.2) }, - { who: 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', value: joy(0.2) }, - { who: 'j4SgrgDrzzGyfrxPe4ZgaKfByKyLo5SdsUXNfHzZJPh5R6f8q', value: joy(0.2) }, - { who: 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', value: joy(0.2) }, - { who: 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', value: joy(0.2) }, - { who: 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', value: joy(0.2) }, - ], - }, + erasTotalStake: joy(130_000), + slashingSpans: { spanIndex: 18, lastStart: 1331, lastNonzeroSlash: 0, prior: [70, 1, 164], }, - validators: { - entries: [ - [ - { args: ['j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D'] }, - { commission: 0.05 * 10 ** 9, blocked: false }, - ], - [ - { args: ['j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW'] }, - { commission: 0.1 * 10 ** 9, blocked: false }, - ], - [ - { args: ['j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP'] }, - { commission: 0.05 * 10 ** 9, blocked: false }, - ], - [ - { args: ['j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz'] }, - { commission: 0.15 * 10 ** 9, blocked: false }, - ], - [ - { args: ['j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa'] }, - { commission: 0.2 * 10 ** 9, blocked: false }, - ], - [ - { args: ['j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN'] }, - { commission: 0.01 * 10 ** 9, blocked: false }, - ], - [ - { args: ['j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP'] }, - { commission: 0.03 * 10 ** 9, blocked: false }, - ], - [ - { args: ['j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ'] }, - { commission: 0.05 * 10 ** 9, blocked: false }, - ], - [ - { args: ['j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg'] }, - { commission: 0.05 * 10 ** 9, blocked: false }, - ], - [ - { args: ['j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt'] }, - { commission: 0.05 * 10 ** 9, blocked: false }, - ], - [ - { args: ['j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM'] }, - { commission: 0.05 * 10 ** 9, blocked: false }, - ], - ], - }, }, }, }, + gql: { queries: [ { @@ -456,21 +131,21 @@ export const TestsFilters: Story = { await selectFromDropdown(screen, verificationFilter, 'verified') await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(3)) expect(screen.queryByText('unverifed')).toBeNull() - expect(screen.getByText('alice')) - expect(screen.queryByText('bob')).toBeNull() + expect(screen.getAllByText('alice').length).toEqual(2) + expect(screen.queryByText('dave')).toBeNull() await selectFromDropdown(screen, verificationFilter, 'unverified') - await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(8)) + await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(6)) expect(screen.queryByText('verifed')).toBeNull() expect(screen.queryByText('alice')).toBeNull() - expect(screen.getByText('bob')) + expect(screen.getByText('dave')) await selectFromDropdown(screen, verificationFilter, 'All') }) await step('State Filter', async () => { await selectFromDropdown(screen, stateFilter, 'active') - await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(9)) + await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(5)) expect(screen.queryByText('waiting')).toBeNull() await selectFromDropdown(screen, stateFilter, 'waiting') - await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(2)) + await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(4)) expect(screen.queryByText('active')).toBeNull() await selectFromDropdown(screen, stateFilter, 'All') }) @@ -480,15 +155,15 @@ export const TestsFilters: Story = { await userEvent.type(searchElement, '{enter}') expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(1) }) - expect(screen.queryByText('charlie')) + expect(screen.queryByText('alice')) await userEvent.clear(searchElement) await userEvent.type(searchElement, 'j4R') await waitFor(async () => { await userEvent.type(searchElement, '{enter}') - expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(9) + expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(7) }) - expect(screen.queryByText('alice')) expect(screen.queryByText('bob')) + expect(screen.queryByText('dave')) }) await step('Clear Filter', async () => { @@ -497,35 +172,25 @@ export const TestsFilters: Story = { await selectFromDropdown(screen, stateFilter, 'active') await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(3)) await userEvent.click(screen.getByText('Clear all filters')) - await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(11)) - await userEvent.type(searchElement, 'j4R{enter}') - await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(9)) + await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(7)) + await userEvent.type(searchElement, 'alice{enter}') + await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(2)) expect(screen.queryByText('Clear all filters')) await userEvent.click(screen.getByText('Clear all filters')) - await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(11)) + await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(7)) }) await step('Sort', async () => { - await userEvent.click(screen.getByText('Expected Nom APR')) - expect( - screen.queryAllByRole('button', { name: 'Nominate' })[0].parentElement?.querySelectorAll('p')[0].innerText === - '18%' - ).toBeTruthy() - await userEvent.click(screen.getByText('Expected Nom APR')) - expect( - screen.queryAllByRole('button', { name: 'Nominate' })[0].parentElement?.querySelectorAll('p')[0].innerText === - '2%' - ).toBeTruthy() await userEvent.click(screen.getByText('Commission')) - expect( - screen.queryAllByRole('button', { name: 'Nominate' })[0].parentElement?.querySelectorAll('p')[1].innerText === - '20%' - ).toBeTruthy() + await waitFor(async () => { + const firstRow = (await screen.getAllByRole('button', { name: 'Nominate' }))[0].parentElement + expect(within(firstRow as HTMLElement).queryByText('20%')) + }) await userEvent.click(screen.getByText('Commission')) - expect( - screen.queryAllByRole('button', { name: 'Nominate' })[0].parentElement?.querySelectorAll('p')[1].innerText === - '1%' - ).toBeTruthy() + await waitFor(async () => { + const firstRow = (await screen.getAllByRole('button', { name: 'Nominate' }))[0].parentElement + expect(within(firstRow as HTMLElement).queryByText('1%')) + }) }) }, } diff --git a/packages/ui/src/app/pages/Validators/ValidatorList.tsx b/packages/ui/src/app/pages/Validators/ValidatorList.tsx index 9058c69bc8..1e0ce707e2 100644 --- a/packages/ui/src/app/pages/Validators/ValidatorList.tsx +++ b/packages/ui/src/app/pages/Validators/ValidatorList.tsx @@ -1,8 +1,11 @@ import React from 'react' +import styled from 'styled-components' +import { PageHeader } from '@/app/components/PageHeader' import { PageLayout } from '@/app/components/PageLayout' import { RowGapBlock } from '@/common/components/page/PageContent' import { Statistics } from '@/common/components/statistics' +import { BN_ZERO } from '@/common/constants' import { Era } from '@/validators/components/statistics/Era' import { Rewards } from '@/validators/components/statistics/Rewards' import { Staking } from '@/validators/components/statistics/Staking' @@ -12,47 +15,67 @@ import { ValidatorsList } from '@/validators/components/ValidatorsList' import { useStakingStatistics } from '@/validators/hooks/useStakingStatistics' import { useValidatorsList } from '@/validators/hooks/useValidatorsList' +import { ValidatorsTabs } from './components/ValidatorsTabs' export const ValidatorList = () => { + const { validatorsWithDetails, validatorsQueries, allValidatorsCount, format } = useValidatorsList() + const { + eraIndex, eraStartedOn, - eraDuration, - now, - eraRewardPoints, totalRewards, lastRewards, idealStaking, - currentStaking, + eraStake, stakingPercentage, activeValidatorsCount, - allValidatorsCount, - acitveNominatorsCount, + activeNominatorsCount, allNominatorsCount, - } = useStakingStatistics() - const { visibleValidators, filter } = useValidatorsList() + } = useStakingStatistics(validatorsQueries) return ( - + } /> + + - + - - + + } - main={} + main={ + + } /> ) } + +const StatisticsStyle = styled(Statistics)` + grid-template-columns: 1fr; + + @media (min-width: 768px) { + grid-template-columns: 1fr 1fr; + } + + @media (min-width: 1440px) { + grid-template-columns: repeat(4, 1fr); + } +` diff --git a/packages/ui/src/app/pages/Validators/ValidatorsModule.tsx b/packages/ui/src/app/pages/Validators/ValidatorsModule.tsx new file mode 100644 index 0000000000..1c943a918b --- /dev/null +++ b/packages/ui/src/app/pages/Validators/ValidatorsModule.tsx @@ -0,0 +1,18 @@ +import React from 'react' +import { Route, Switch } from 'react-router' + +import { ValidatorsRoutes } from '@/validators/constants/routes' +import { ValidatorsInfo } from '@/validators/modals/ValidatorsInfo' + +import { ValidatorList } from './ValidatorList' + +export const ValidatorsModule = () => { + return ( + <> + + + + + + ) +} diff --git a/packages/ui/src/app/pages/Validators/components/ValidatorsTabs.tsx b/packages/ui/src/app/pages/Validators/components/ValidatorsTabs.tsx new file mode 100644 index 0000000000..2f810d78c5 --- /dev/null +++ b/packages/ui/src/app/pages/Validators/components/ValidatorsTabs.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +import { usePageTabs } from '@/app/hooks/usePageTabs' +import { Tabs } from '@/common/components/Tabs' +import { ValidatorsRoutes } from '@/validators/constants/routes' + +export const ValidatorsTabs = () => { + const tabs = usePageTabs([['Validator List', ValidatorsRoutes.list]]) + + return +} diff --git a/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.stories.tsx b/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.stories.tsx index 8365f9d651..329777669d 100644 --- a/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.stories.tsx +++ b/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.stories.tsx @@ -320,7 +320,9 @@ export const CreateOpening: Story = { }) step('Transaction parameters', () => { - const [description, openingType, stakePolicy, rewardPerBlock] = args.onCreateOpening.mock.calls.at(-1) + const [signer, description, openingType, stakePolicy, rewardPerBlock] = args.onCreateOpening.mock.calls.at(-1) + + expect(signer).toBe(member('alice').controllerAccount) expect(stakePolicy.toJSON()).toEqual({ stakeAmount: 100_0000000000, @@ -397,7 +399,9 @@ export const CreateOpeningImport: Story = { }) step('Transaction parameters', () => { - const [description, openingType, stakePolicy, rewardPerBlock] = args.onCreateOpening.mock.calls.at(-1) + const [signer, description, openingType, stakePolicy, rewardPerBlock] = args.onCreateOpening.mock.calls.at(-1) + + expect(signer).toBe(member('alice').controllerAccount) expect(stakePolicy.toJSON()).toEqual({ stakeAmount: 200_0000000000, diff --git a/packages/ui/src/common/components/ListItemLoader.tsx b/packages/ui/src/common/components/ListItemLoader.tsx index 61e55e8450..c5ca74634d 100644 --- a/packages/ui/src/common/components/ListItemLoader.tsx +++ b/packages/ui/src/common/components/ListItemLoader.tsx @@ -9,24 +9,30 @@ interface ListItemLoaderProps { count?: number height?: string id?: string + gap?: string + padding?: string } -export const ListItemLoader = ({ children, count = 1, id, ...styleProps }: ListItemLoaderProps) => { +export const ListItemLoader = ({ children, count = 1, id, gap, ...styleProps }: ListItemLoaderProps) => { return ( - + {repeat( (index) => ( - + {children} - + ), count )} - + ) } -const Wrapper = styled.div` +const ListWrapper = styled.div<{ gap?: string }>` + gap: ${({ gap }) => gap ?? '0'}; +` + +const ItemWrapper = styled.div` width: 100%; display: grid; height: ${({ height }) => height ?? '94px'}; @@ -34,5 +40,5 @@ const Wrapper = styled.div` grid-template-columns: ${({ columnsTemplate }) => columnsTemplate}; justify-content: space-between; align-items: center; - padding: 16px 8px 16px 16px; + padding: ${({ padding }) => padding ?? '16px 8px 16px 16px'}; ` diff --git a/packages/ui/src/common/components/Modal/Modals.tsx b/packages/ui/src/common/components/Modal/Modals.tsx index bd2cc2af01..5752ab32c5 100644 --- a/packages/ui/src/common/components/Modal/Modals.tsx +++ b/packages/ui/src/common/components/Modal/Modals.tsx @@ -17,6 +17,17 @@ export const Row = styled.div` height: auto; ` +export const RowInline = styled.div<{ justify?: string; gap?: number; top?: number }>` + display: flex; + flex-direction: row; + width: 100%; + height: auto; + align-items: center; + justify-content: ${({ justify }) => justify ?? 'flex-start'}; + gap: ${({ gap }) => gap ?? 16}px; + margin-top: ${({ top }) => top ?? 0}px; +` + export const AccountRow = styled.div` display: grid; grid-template-columns: 1fr 1fr; diff --git a/packages/ui/src/common/components/Pagination/Pagination.tsx b/packages/ui/src/common/components/Pagination/Pagination.tsx index 7962ce3ecf..44148bc372 100644 --- a/packages/ui/src/common/components/Pagination/Pagination.tsx +++ b/packages/ui/src/common/components/Pagination/Pagination.tsx @@ -7,7 +7,7 @@ import { Arrow } from '@/common/components/icons' import { BorderRad, Colors, Fonts, Transitions } from '@/common/constants/styles' import { useResponsive } from '@/common/hooks/useResponsive' -interface PaginationProps { +export interface PaginationProps { pageCount?: number handlePageChange: (page: number) => void page?: number diff --git a/packages/ui/src/common/components/Warning.tsx b/packages/ui/src/common/components/Warning.tsx index f0b2585578..136fcb521e 100644 --- a/packages/ui/src/common/components/Warning.tsx +++ b/packages/ui/src/common/components/Warning.tsx @@ -32,10 +32,10 @@ export const Warning = ({ title, content, isClosable, additionalContent, icon, i {icon === 'alert' && } {icon === 'info' && } - {title &&
{title}
} + {title ?
{title}
: content}
)} - {content && ( + {title && content && ( {content} diff --git a/packages/ui/src/common/components/forms/FilterBox/FilterSearchBox.tsx b/packages/ui/src/common/components/forms/FilterBox/FilterSearchBox.tsx index 57f43cdd5d..8defc1cb60 100644 --- a/packages/ui/src/common/components/forms/FilterBox/FilterSearchBox.tsx +++ b/packages/ui/src/common/components/forms/FilterBox/FilterSearchBox.tsx @@ -6,7 +6,6 @@ import { InputComponent, InputNotification, InputText } from '@/common/component import { CrossIcon, SearchIcon } from '@/common/components/icons' import { Colors } from '@/common/constants' -import { useDebounce } from '../../../hooks/useDebounce' import { ButtonLink } from '../../buttons' import { ControlProps } from '../types' @@ -51,11 +50,19 @@ interface SearchBoxProps extends ControlProps { } export const SearchBox = React.memo( ({ value, onApply, onChange, label, displayReset, minCharacterLimit = 3 }: SearchBoxProps) => { - const debouncedValue = useDebounce(value, 300) const change = onChange && (({ target }: ChangeEvent) => onChange(target.value)) - const isValid = () => !debouncedValue || debouncedValue.length === 0 || debouncedValue.length >= minCharacterLimit - const keyDown = !isValid() || !onApply ? undefined : ({ key }: React.KeyboardEvent) => key === 'Enter' && onApply() + const isValid = !value || value.length === 0 || value.length >= minCharacterLimit + const [showInvalid, setShowInvalid] = useState(false) + useEffect(() => { + if (isValid) setShowInvalid(false) + }, [isValid]) + + const keyDown = ({ key }: React.KeyboardEvent) => { + if (key !== 'Enter') return + if (!isValid) return setShowInvalid(true) + onApply?.() + } const reset = onChange && @@ -70,8 +77,8 @@ export const SearchBox = React.memo( {label} {displayReset && value && ( diff --git a/packages/ui/src/common/components/forms/Label.tsx b/packages/ui/src/common/components/forms/Label.tsx index 4870e99308..fb7045d834 100644 --- a/packages/ui/src/common/components/forms/Label.tsx +++ b/packages/ui/src/common/components/forms/Label.tsx @@ -4,6 +4,7 @@ import { Colors } from '../../constants' import { TooltipContainer } from '../Tooltip' interface LabelProps { + noMargin?: boolean isRequired?: boolean className?: string } @@ -13,7 +14,7 @@ export const Label = styled.label` align-items: center; align-content: center; width: fit-content; - margin-bottom: 4px; + margin-bottom: ${({ noMargin }) => (noMargin ? '0px' : '4px')}; font-size: 14px; line-height: 20px; font-weight: 700; diff --git a/packages/ui/src/common/components/forms/ToggleCheckbox.tsx b/packages/ui/src/common/components/forms/ToggleCheckbox.tsx index 954c275680..37401c3fb3 100644 --- a/packages/ui/src/common/components/forms/ToggleCheckbox.tsx +++ b/packages/ui/src/common/components/forms/ToggleCheckbox.tsx @@ -20,7 +20,7 @@ export interface Props { onBlur?: any } -function BaseToggleCheckbox({ +export function BaseToggleCheckbox({ id, isRequired, disabled, @@ -118,13 +118,13 @@ const ToggleStyledInput = styled.label` margin: 0 10px; position: relative; border-radius: ${BorderRad.full}; - background-color: ${(hasNoOffState) => (hasNoOffState ? Colors.Blue[500] : Colors.Black[300])}; + background-color: ${({ hasNoOffState }) => (hasNoOffState ? Colors.Blue[500] : Colors.Black[300])}; cursor: pointer; transition: ${Transitions.all}; &:hover, &:focus { - background-color: ${(hasNoOffState) => (hasNoOffState ? Colors.Blue[400] : Colors.Black[200])}; + background-color: ${({ hasNoOffState }) => (hasNoOffState ? Colors.Blue[400] : Colors.Black[200])}; } &:after { diff --git a/packages/ui/src/common/components/icons/index.ts b/packages/ui/src/common/components/icons/index.ts index 1c0c748405..9c1d757280 100644 --- a/packages/ui/src/common/components/icons/index.ts +++ b/packages/ui/src/common/components/icons/index.ts @@ -33,3 +33,4 @@ export * from './ApplicationIcon' export * from './CouncilMemberIcon' export * from './VerifiedMemberIcon' export * from './MenuIcon' +export * from './PlusIcon' diff --git a/packages/ui/src/common/components/statistics/TokenValueStat.tsx b/packages/ui/src/common/components/statistics/TokenValueStat.tsx index 70c46dbdff..beb87358ac 100644 --- a/packages/ui/src/common/components/statistics/TokenValueStat.tsx +++ b/packages/ui/src/common/components/statistics/TokenValueStat.tsx @@ -17,7 +17,7 @@ export interface TokenValueStatProps extends StatisticItemProps { export const TokenValueStat: FC = (props) => { return ( - + {props.children} ) diff --git a/packages/ui/src/common/components/typography/NotFoundText.tsx b/packages/ui/src/common/components/typography/NotFoundText.tsx index a263fe66a8..9c8a3c68cf 100644 --- a/packages/ui/src/common/components/typography/NotFoundText.tsx +++ b/packages/ui/src/common/components/typography/NotFoundText.tsx @@ -3,6 +3,7 @@ import styled from 'styled-components' import { TextBig } from '.' export const NotFoundText = styled(TextBig).attrs({ lighter: true })` - justify-self: center; + display: flex; + justify-content: center; margin: 48px 16px; ` diff --git a/packages/ui/src/common/constants/numbers.ts b/packages/ui/src/common/constants/numbers.ts index ff4b4ccb1d..5898c14100 100644 --- a/packages/ui/src/common/constants/numbers.ts +++ b/packages/ui/src/common/constants/numbers.ts @@ -1,10 +1,14 @@ import BN from 'bn.js' -export const JOY_DECIMAL_PLACES = 10 +import { CHAIN_PROPERTIES } from '@/app/constants/chain' + +export const JOY_DECIMAL_PLACES = CHAIN_PROPERTIES.tokenDecimals[0] export const ED = new BN(10) export const BN_ZERO = new BN(0) export const SECONDS_PER_BLOCK = 6 export const ERA_DURATION = 21600000 -export const ERAS_PER_YEAR = 1460 +export const ERAS_PER_DAY = 4 +export const ERA_PER_MONTH = ERAS_PER_DAY * 30 +export const ERAS_PER_YEAR = ERAS_PER_DAY * 365 export const ERA_DEPTH = 120 diff --git a/packages/ui/src/common/model/ObservableList.ts b/packages/ui/src/common/model/ObservableList.ts new file mode 100644 index 0000000000..8c456318d9 --- /dev/null +++ b/packages/ui/src/common/model/ObservableList.ts @@ -0,0 +1,38 @@ +import { Observable, OperatorFunction, combineLatest, map, merge, of, pipe, switchMap, take } from 'rxjs' + +export const mapObservableList = + (mapFn: (item: T) => Observable): OperatorFunction => + (list$) => + list$.pipe(switchMap((list) => (list.length ? combineLatest(list.map(mapFn)) : of([])))) + +export const filterObservableList = (predicate: (item: T) => Observable): OperatorFunction => + pipe( + mapObservableList((item: T) => + predicate(item).pipe( + take(1), + map((shouldShow) => [!!shouldShow, item] as const) + ) + ), + map((mapped) => mapped.filter(([shouldShow]) => shouldShow).map(([, item]) => item)) + ) + +export const sortObservableList = ( + mapFn: (item: T) => Observable, + compareFn: (a: S, b: S) => number +): OperatorFunction => + pipe( + mapFn && + mapObservableList((item: T) => + mapFn(item).pipe( + take(1), + map((sortParam) => [sortParam, item] as const) + ) + ), + map((mapped) => mapped.sort((a, b) => compareFn(a[0], b[0])).map(([, item]) => item)), + setDefault([]) + ) + +const setDefault = + (defaultValue: T): OperatorFunction => + (o$) => + merge(of(defaultValue), o$) diff --git a/packages/ui/src/common/model/Polyfill.ts b/packages/ui/src/common/model/Polyfill.ts new file mode 100644 index 0000000000..b6706f6342 --- /dev/null +++ b/packages/ui/src/common/model/Polyfill.ts @@ -0,0 +1,11 @@ +import { isDefined } from '../utils' + +export const toSpliced = (array: T[], start: number, deleteCount?: number, ...items: T[]): T[] => { + const hasDeleteCount = isDefined(deleteCount) + + if ('toSpliced' in Array.prototype) { + return hasDeleteCount ? (array as any).toSpliced(start, deleteCount, ...items) : (array as any).toSpliced(start) + } + + return [...array.slice(0, start), ...items, ...(hasDeleteCount ? array.slice(start + deleteCount) : [])] +} diff --git a/packages/ui/src/common/model/createType.ts b/packages/ui/src/common/model/createType.ts index b7616be24f..caef40a125 100644 --- a/packages/ui/src/common/model/createType.ts +++ b/packages/ui/src/common/model/createType.ts @@ -1,7 +1,10 @@ -import { createType } from '@joystream/types' +import { createType, registry } from '@joystream/types' +import { GenericChainProperties } from '@polkadot/types' import type { AccountId, Perbill } from '@polkadot/types/interfaces' import { Codec, DetectCodec } from '@polkadot/types/types' +import { CHAIN_PROPERTIES } from '@/app/constants/chain' + const TypeMap = { AccountId: 'AccountId', LockIdentifier: 'Raw', @@ -95,3 +98,5 @@ const createSafeType = any, context?: AnyObject ): [any, any][] => entries.map(([key, value]) => [key, transform(value, key, context)]) + +export const has = >(key: K, o: T): key is Extract => + key in o diff --git a/packages/ui/src/memberships/components/MemberListItem/MyMember.tsx b/packages/ui/src/memberships/components/MemberListItem/MyMember.tsx index a6590b846a..07af94907e 100644 --- a/packages/ui/src/memberships/components/MemberListItem/MyMember.tsx +++ b/packages/ui/src/memberships/components/MemberListItem/MyMember.tsx @@ -47,7 +47,7 @@ export const MyMemberListItem = ({ member }: { member: Member }) => { - + diff --git a/packages/ui/src/memberships/components/MemberProfile/MemberDetails.tsx b/packages/ui/src/memberships/components/MemberProfile/MemberDetails.tsx index 57030ff329..73691375de 100644 --- a/packages/ui/src/memberships/components/MemberProfile/MemberDetails.tsx +++ b/packages/ui/src/memberships/components/MemberProfile/MemberDetails.tsx @@ -13,6 +13,8 @@ import { SidePaneLabel, EmptyBody, } from '@/common/components/SidePane' +import { has } from '@/common/utils/object' +import { ExternalResourceLink } from '@/memberships/constants' import { useIsMyMembership } from '@/memberships/hooks/useIsMyMembership' import { useMemberExtraInfo } from '@/memberships/hooks/useMemberExtraInfo' @@ -37,15 +39,6 @@ export const MemberDetails = React.memo(({ member }: Props) => { initiatingLeaving = '-', } = useMemberExtraInfo(member) - const externalResourceLink: any = { - TELEGRAM: 'https://web.telegram.org/k/#@', - TWITTER: 'https://twitter.com/', - FACEBOOK: 'https://facebook.com/', - YOUTUBE: 'https://youtube.com/user/', - LINKEDIN: 'https://www.linkedin.com/in/', - GITHUB: 'https://github.com/', - } - if (isLoading || !memberDetails) { return ( @@ -143,9 +136,9 @@ export const MemberDetails = React.memo(({ member }: Props) => { - {externalResourceLink[externalResource.source] ? ( + {has(externalResource.source, ExternalResourceLink) ? ( {externalResource.value} diff --git a/packages/ui/src/memberships/components/SelectValidatorAccounts.tsx b/packages/ui/src/memberships/components/SelectValidatorAccounts.tsx new file mode 100644 index 0000000000..866a226904 --- /dev/null +++ b/packages/ui/src/memberships/components/SelectValidatorAccounts.tsx @@ -0,0 +1,188 @@ +import React, { useCallback, useEffect, useMemo, useReducer } from 'react' +import styled from 'styled-components' + +import { SelectAccount } from '@/accounts/components/SelectAccount' +import { Account } from '@/accounts/types' +import { ButtonGhost, ButtonPrimary } from '@/common/components/buttons' +import { BaseToggleCheckbox, InputComponent, Label } from '@/common/components/forms' +import { CrossIcon, PlusIcon } from '@/common/components/icons' +import { AlertSymbol } from '@/common/components/icons/symbols' +import { Row, RowInline } from '@/common/components/Modal' +import { Tooltip, TooltipDefault } from '@/common/components/Tooltip' +import { TextMedium, TextSmall } from '@/common/components/typography' +import { toSpliced } from '@/common/model/Polyfill' +import { useValidators } from '@/validators/hooks/useValidators' + +type SelectValidatorAccountsState = { + isValidator: boolean + accounts: (Account | undefined)[] +} + +type Action = + | { type: 'SetInitialAccounts'; value: Account[] } + | { type: 'ToggleIsValidator'; value: boolean } + | { type: 'AddAccount'; value: { index: number; account?: Account } } + | { type: 'RemoveAccount'; value: { index: number } } + +const reducer = (state: SelectValidatorAccountsState, action: Action): SelectValidatorAccountsState => { + switch (action.type) { + case 'SetInitialAccounts': { + return { isValidator: true, accounts: action.value } + } + case 'ToggleIsValidator': { + return { ...state, isValidator: action.value } + } + case 'AddAccount': { + const { index, account } = action.value + return { ...state, accounts: toSpliced(state.accounts, index, 1, account) } + } + case 'RemoveAccount': { + const { index } = action.value + return { ...state, accounts: toSpliced(state.accounts, index, 1) } + } + } +} + +type UseSelectValidatorAccounts = { + isValidatorAccount: (account: Account) => boolean + initialValidatorAccounts: Account[] + state: SelectValidatorAccountsState + onChange: (action: Action) => void +} +export const useSelectValidatorAccounts = (boundAccounts: Account[] = []): UseSelectValidatorAccounts => { + const [state, dispatch] = useReducer(reducer, { isValidator: false, accounts: [] }) + + const validators = useValidators({ skip: !state.isValidator && boundAccounts.length === 0 }) + const validatorAddresses = useMemo( + () => validators?.flatMap(({ stashAccount: stash, controllerAccount: ctrl }) => (ctrl ? [stash, ctrl] : [stash])), + [validators] + ) + + const isValidatorAccount = useCallback( + (account: Account) => !!validatorAddresses?.includes(account.address), + [validatorAddresses] + ) + + const initialValidatorAccounts = useMemo( + () => boundAccounts.filter(isValidatorAccount), + [boundAccounts, validatorAddresses] + ) + + useEffect(() => { + if (initialValidatorAccounts.length > 0) { + dispatch({ type: 'SetInitialAccounts', value: initialValidatorAccounts }) + } + }, [initialValidatorAccounts]) + + return { initialValidatorAccounts, state, isValidatorAccount, onChange: dispatch } +} + +export const SelectValidatorAccounts = ({ isValidatorAccount, state, onChange }: UseSelectValidatorAccounts) => { + const handleIsValidatorChange = (value: boolean) => onChange({ type: 'ToggleIsValidator', value }) + + const AddAccount = (index: number, account: Account | undefined) => + onChange({ type: 'AddAccount', value: { index, account } }) + const RemoveAccount = (index: number) => onChange({ type: 'RemoveAccount', value: { index } }) + + const validatorAccountSelectorFilter = (index: number, account: Account) => + toSpliced(state.accounts, index, 1).every( + (accountOrUndefined) => accountOrUndefined?.address !== account.address + ) && isValidatorAccount(account) + + return ( + <> + + + + + + {state.isValidator && ( + <> + + + + + + + * + + + If your validator account is not in your signer wallet, paste the account address to the field below: + + {state.accounts.map((account, index) => ( + + + + AddAccount(index, account)} + filter={(account) => validatorAccountSelectorFilter(index, account)} + /> + + { + RemoveAccount(index) + }} + className="remove-button" + > + + + + {account && !isValidatorAccount(account) && ( + + + + + + + + This account is neither a validator controller account nor a validator stash account. + + + )} + + ))} + + AddAccount(state.accounts.length, undefined)} + > + Add Validator Account + + + + + )} + + ) +} + +const SelectValidatorAccountWrapper = styled.div` + margin-top: -4px; + display: flex; + flex-direction: column; + gap: 8px; +` + +const InputNotificationIcon = styled.div` + display: flex; + justify-content: center; + align-items: center; + width: 12px; + height: 12px; + color: inherit; + padding-right: 2px; + + .blackPart, + .primaryPart { + fill: currentColor; + } +` diff --git a/packages/ui/src/memberships/constants/externalResourceLink.ts b/packages/ui/src/memberships/constants/externalResourceLink.ts new file mode 100644 index 0000000000..ac2740193c --- /dev/null +++ b/packages/ui/src/memberships/constants/externalResourceLink.ts @@ -0,0 +1,8 @@ +export const ExternalResourceLink = { + TELEGRAM: 'https://t.me/', + TWITTER: 'https://twitter.com/', + FACEBOOK: 'https://facebook.com/', + YOUTUBE: 'https://youtube.com/user/', + LINKEDIN: 'https://www.linkedin.com/in/', + GITHUB: 'https://github.com/', +} as const diff --git a/packages/ui/src/memberships/constants/index.ts b/packages/ui/src/memberships/constants/index.ts index 856f210744..d21e69ee07 100644 --- a/packages/ui/src/memberships/constants/index.ts +++ b/packages/ui/src/memberships/constants/index.ts @@ -1 +1,2 @@ export * from './email' +export * from './externalResourceLink' diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/AddStakingAccCandidateModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/AddStakingAccCandidateModal.tsx new file mode 100644 index 0000000000..dcec3a0b41 --- /dev/null +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/AddStakingAccCandidateModal.tsx @@ -0,0 +1,34 @@ +import { SubmittableExtrinsic } from '@polkadot/api/types' +import { ISubmittableResult } from '@polkadot/types/types' +import React from 'react' +import { ActorRef } from 'xstate' + +import { Account } from '@/accounts/types' +import { TextMedium } from '@/common/components/typography' +import { SignTransactionModal } from '@/common/modals/SignTransactionModal/SignTransactionModal' + +interface SignProps { + transaction: SubmittableExtrinsic<'rxjs', ISubmittableResult> | undefined + signer: Account + service: ActorRef +} + +export const AddStakingAccCandidateModal = ({ transaction, signer, service }: SignProps) => ( + + You are intending to bond your validator account with your membership. + +) diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx index 060406efa2..488f7ecf7b 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx @@ -1,5 +1,6 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' import { BalanceOf } from '@polkadot/types/interfaces/runtime' +import { uniqBy } from 'lodash' import React, { useEffect, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' import styled from 'styled-components' @@ -38,6 +39,7 @@ import { TextMedium } from '@/common/components/typography' import { definedValues } from '@/common/utils' import { useYupValidationResolver } from '@/common/utils/validation' import { AvatarInput } from '@/memberships/components/AvatarInput' +import { SelectValidatorAccounts, useSelectValidatorAccounts } from '@/memberships/components/SelectValidatorAccounts' import { SocialMediaSelector } from '@/memberships/components/SocialMediaSelector/SocialMediaSelector' import { useUploadAvatarAndSubmit } from '@/memberships/hooks/useUploadAvatarAndSubmit' import { useGetMembersCountQuery } from '@/memberships/queries' @@ -88,6 +90,7 @@ export interface MemberFormFields { about: string avatarUri: File | string | null isReferred?: boolean + validatorAccounts?: Account[] referrer?: Member hasTerms?: boolean invitor?: Member @@ -101,6 +104,7 @@ const formDefaultValues = { about: '', avatarUri: null, isReferred: false, + validatorAccounts: [], referrer: undefined, hasTerms: false, externalResources: {}, @@ -137,6 +141,12 @@ export const BuyMembershipForm = ({ const [handle, isReferred, referrer, captchaToken] = form.watch(['handle', 'isReferred', 'referrer', 'captchaToken']) + const selectValidatorAccounts = useSelectValidatorAccounts() + const { + isValidatorAccount, + state: { isValidator, accounts: validatorAccounts }, + } = selectValidatorAccounts + useEffect(() => { if (handle) { setFormHandleMap(handle) @@ -149,10 +159,22 @@ export const BuyMembershipForm = ({ } }, [data?.membershipsConnection.totalCount]) - const isFormValid = !isUploading && form.formState.isValid + const isFormValid = + !isUploading && + form.formState.isValid && + (!isValidator || + (validatorAccounts.length > 0 && validatorAccounts.every((account) => account && isValidatorAccount(account)))) + const isDisabled = type === 'onBoarding' && process.env.REACT_APP_CAPTCHA_SITE_KEY ? !captchaToken || !isFormValid : !isFormValid + const submit = () => { + const accounts = uniqBy(validatorAccounts as Account[], 'address') + form.setValue('validatorAccounts', accounts) + const values = form.getValues() + uploadAvatarAndSubmit({ ...values, externalResources: { ...definedValues(values.externalResources) } }) + } + return ( <> @@ -234,6 +256,8 @@ export const BuyMembershipForm = ({ + {type === 'general' && } + {process.env.REACT_APP_CAPTCHA_SITE_KEY && type === 'onBoarding' && ( - {type === 'onBoarding' && ( @@ -282,14 +305,7 @@ export const BuyMembershipForm = ({ /> )} - { - const values = form.getValues() - uploadAvatarAndSubmit({ ...values, externalResources: { ...definedValues(values.externalResources) } }) - }} - disabled={isDisabled} - > + {isUploading ? : 'Create a Membership'} diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx index c73b25f672..8ba2588ec6 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipModal.tsx @@ -1,5 +1,5 @@ import { useApolloClient } from '@apollo/client' -import React, { useEffect } from 'react' +import React, { useEffect, useMemo } from 'react' import { useApi } from '@/api/hooks/useApi' import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' @@ -7,9 +7,11 @@ import { useMachine } from '@/common/hooks/useMachine' import { useModal } from '@/common/hooks/useModal' import { toMemberTransactionParams } from '@/memberships/modals/utils' +import { AddStakingAccCandidateModal } from './AddStakingAccCandidateModal' import { BuyMembershipFormModal, MemberFormFields } from './BuyMembershipFormModal' import { BuyMembershipSignModal } from './BuyMembershipSignModal' import { BuyMembershipSuccessModal } from './BuyMembershipSuccessModal' +import { ConfirmStakingAccModal } from './ConfirmStakingAccModal' import { buyMembershipMachine } from './machine' export const BuyMembershipModal = () => { @@ -27,29 +29,71 @@ export const BuyMembershipModal = () => { apolloClient.refetchQueries({ include: 'active' }) }, [isSuccessful, apolloClient]) + const memberId = state.context.memberId?.toString() + + const buyTransaction = useMemo( + () => state.context.form && api?.tx.members.buyMembership(toMemberTransactionParams(state.context.form)), + [api?.isConnected, state.context.form] + ) + const bindTransaction = useMemo( + () => memberId && api?.tx.members.addStakingAccountCandidate(memberId), + [api?.isConnected, memberId] + ) + const conFirmTransaction = useMemo(() => { + const validatorAccounts = state.context.form?.validatorAccounts + + if (!api || !memberId || !validatorAccounts) return + + const confirmTxs = validatorAccounts.map(({ address }) => api.tx.members.confirmStakingAccount(memberId, address)) + + return confirmTxs.length > 1 ? api.tx.utility.batch(confirmTxs) : confirmTxs[0] + }, [api?.isConnected, memberId, state.context.form?.validatorAccounts]) + if (state.matches('prepare')) { const onSubmit = (params: MemberFormFields) => send({ type: 'DONE', form: params }) return } - if (state.matches('transaction') && api) { - const transaction = api.tx.members.buyMembership(toMemberTransactionParams(state.context.form)) + if (state.matches('buyMembershipTx') && buyTransaction) { const { form } = state.context - const service = state.children.transaction + const service = state.children.buyMembership return ( ) } + if (state.matches('addStakingAccCandidateTx') && bindTransaction && state.context.form.validatorAccounts) { + const service = state.children.addStakingAccCandidate + + return ( + + ) + } + + if (state.matches('confirmStakingAccTx') && conFirmTransaction && state.context.form.controllerAccount) { + const service = state.children.confirmStakingAcc + return ( + + ) + } + if (isSuccessful) { const { form, memberId } = state.context return diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx index 904d48c5b0..3cd2aa8591 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx @@ -68,11 +68,31 @@ export const BuyMembershipSignModal = ({ } }, [!balance, !membershipPrice, !paymentInfo]) + const shouldBindValidatorAccounts = formData.validatorAccounts?.length const signDisabled = !isReady || !hasFunds || !validationInfo return ( - + - You intend to create a new membership. + + {shouldBindValidatorAccounts + ? 'You intend to create a validator membership.' + : 'You intend to create a new membership.'} + The creation of the new membership costs . @@ -108,7 +128,11 @@ export const BuyMembershipSignModal = ({ | undefined + signer: Account + service: ActorRef +} + +export const ConfirmStakingAccModal = ({ transaction, signer, service }: SignProps) => ( + + You are intending to confirm your validator account to be bound with your membership + +) diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts b/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts index 54c565af37..814951875c 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts @@ -19,11 +19,14 @@ interface BuyMembershipContext { form?: MemberFormFields memberId?: BN transactionEvents?: EventRecord[] + bindingValidtorAccStep?: number } type BuyMembershipState = | { value: 'prepare'; context: EmptyObject } - | { value: 'transaction'; context: { form: MemberFormFields } } + | { value: 'buyMembershipTx'; context: { form: MemberFormFields } } + | { value: 'addStakingAccCandidateTx'; context: { form: MemberFormFields } } + | { value: 'confirmStakingAccTx'; context: { form: MemberFormFields } } | { value: 'success'; context: Required } | { value: 'canceled'; context: Required } | { value: 'error'; context: { form: MemberFormFields; transactionEvents: EventRecord[] } } @@ -35,28 +38,92 @@ export type BuyMembershipEvent = | { type: 'SUCCESS'; memberId: BN } | { type: 'ERROR' } +const isSelfTransition = (context: BuyMembershipContext) => + !!context.form?.validatorAccounts && + context.form?.validatorAccounts.length > 1 && + (!context.bindingValidtorAccStep || context.form.validatorAccounts.length - 1 > context.bindingValidtorAccStep) + export const buyMembershipMachine = createMachine({ initial: 'prepare', states: { prepare: { on: { DONE: { - target: 'transaction', + target: 'buyMembershipTx', actions: assign({ form: (_, event) => event.form }), }, }, }, - transaction: { + buyMembershipTx: { invoke: { - id: 'transaction', + id: 'buyMembership', src: transactionMachine, onDone: [ + { + target: 'addStakingAccCandidateTx', + actions: assign({ + memberId: (context, event) => getDataFromEvent(event.data.events, 'members', 'MembershipBought', 0), + }), + cond: (context, event) => isTransactionSuccess(context, event) && !!context.form?.validatorAccounts?.length, + }, { target: 'success', actions: assign({ memberId: (context, event) => getDataFromEvent(event.data.events, 'members', 'MembershipBought', 0), }), + cond: (context, event) => isTransactionSuccess(context, event) && !context.form?.validatorAccounts?.length, + }, + { + target: 'error', + cond: isTransactionError, + actions: assign({ transactionEvents: (context, event) => event.data.events }), + }, + { + target: 'canceled', + cond: isTransactionCanceled, + }, + ], + }, + }, + addStakingAccCandidateTx: { + invoke: { + id: 'addStakingAccCandidate', + src: transactionMachine, + onDone: [ + { + target: 'addStakingAccCandidateTx', + cond: isSelfTransition, + actions: assign({ + transactionEvents: (context, event) => event.data.events, + bindingValidtorAccStep: (context) => (context.bindingValidtorAccStep ?? 0) + 1, + }), + }, + { + target: 'confirmStakingAccTx', + cond: isTransactionSuccess, + actions: assign({ transactionEvents: (context, event) => event.data.events }), + }, + { + target: 'error', + cond: isTransactionError, + actions: assign({ transactionEvents: (context, event) => event.data.events }), + }, + { + target: 'canceled', + cond: isTransactionCanceled, + }, + ], + }, + }, + confirmStakingAccTx: { + invoke: { + id: 'confirmStakingAcc', + src: transactionMachine, + onDone: [ + { + target: 'success', cond: isTransactionSuccess, + actions: assign({ transactionEvents: (context, event) => event.data.events }), }, { target: 'error', diff --git a/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx b/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx index 5279349702..2fc3e34a80 100644 --- a/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx +++ b/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useEffect, useState } from 'react' +import { difference } from 'lodash' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useForm, FormProvider } from 'react-hook-form' import * as Yup from 'yup' import { AnySchema } from 'yup' @@ -6,6 +7,7 @@ import { AnySchema } from 'yup' import { filterAccount, SelectAccount } from '@/accounts/components/SelectAccount' import { useMyAccounts } from '@/accounts/hooks/useMyAccounts' import { accountOrNamed } from '@/accounts/model/accountOrNamed' +import { Account } from '@/accounts/types' import { InputComponent, InputText, InputTextarea } from '@/common/components/forms' import { Loading } from '@/common/components/Loading' import { @@ -17,10 +19,12 @@ import { ScrolledModalContainer, } from '@/common/components/Modal' import { TextMedium } from '@/common/components/typography' +import { Warning } from '@/common/components/Warning' import { WithNullableValues } from '@/common/types/form' import { definedValues } from '@/common/utils' import { useYupValidationResolver } from '@/common/utils/validation' import { AvatarInput } from '@/memberships/components/AvatarInput' +import { SelectValidatorAccounts, useSelectValidatorAccounts } from '@/memberships/components/SelectValidatorAccounts' import { SocialMediaSelector } from '@/memberships/components/SocialMediaSelector/SocialMediaSelector' import { useUploadAvatarAndSubmit } from '@/memberships/hooks/useUploadAvatarAndSubmit' import { useGetMembersCountQuery } from '@/memberships/queries' @@ -29,11 +33,14 @@ import { AvatarURISchema, ExternalResourcesSchema, HandleSchema } from '../../mo import { MemberWithDetails } from '../../types' import { UpdateMemberForm } from './types' -import { changedOrNull, hasAnyEdits, membershipExternalResourceToObject } from './utils' +import { changedOrNull, hasAnyEdits, hasAnyMetadateChanges, membershipExternalResourceToObject } from './utils' +type FormFields = Omit & { + validatorAddresses: string[] +} interface Props { onClose: () => void - onSubmit: (params: WithNullableValues) => void + onSubmit: (params: WithNullableValues, memberId: string, controllerAccount: string) => void member: MemberWithDetails } @@ -45,42 +52,78 @@ const UpdateMemberSchema = Yup.object().shape({ externalResources: ExternalResourcesSchema, }) -const getUpdateMemberFormInitial = (member: MemberWithDetails) => ({ - id: member.id, - name: member.name || '', - handle: member.handle || '', - about: member.about || '', - avatarUri: process.env.REACT_APP_AVATAR_UPLOAD_URL ? '' : typeof member.avatar === 'string' ? member.avatar : '', - rootAccount: member.rootAccount, - controllerAccount: member.controllerAccount, - externalResources: membershipExternalResourceToObject(member.externalResources) ?? {}, -}) - export const UpdateMembershipFormModal = ({ onClose, onSubmit, member }: Props) => { const { allAccounts } = useMyAccounts() + + const boundAccounts: Account[] = useMemo( + () => + member.boundAccounts.map( + (address) => + allAccounts.find((account) => account.address === address) ?? + accountOrNamed(allAccounts, address, 'Unsaved account') + ), + [allAccounts, member] + ) + const selectValidatorAccounts = useSelectValidatorAccounts(boundAccounts) + const { + initialValidatorAccounts, + state: { isValidator, accounts: validatorAccounts }, + isValidatorAccount, + } = selectValidatorAccounts + + const updateMemberFormInitial = useMemo( + () => ({ + id: member.id, + name: member.name || '', + handle: member.handle || '', + about: member.about || '', + avatarUri: process.env.REACT_APP_AVATAR_UPLOAD_URL ? '' : typeof member.avatar === 'string' ? member.avatar : '', + rootAccount: member.rootAccount, + controllerAccount: member.controllerAccount, + externalResources: membershipExternalResourceToObject(member.externalResources) ?? {}, + isValidator: initialValidatorAccounts.length > 0, + validatorAddresses: initialValidatorAccounts.map((account) => account.address), + }), + [member, initialValidatorAccounts] + ) + const [handleMap, setHandleMap] = useState(member.handle) const { data } = useGetMembersCountQuery({ variables: { where: { handle_eq: handleMap } } }) const context = { size: data?.membershipsConnection.totalCount, isHandleChanged: handleMap !== member.handle } - const { uploadAvatarAndSubmit, isUploading } = useUploadAvatarAndSubmit((fields) => + + const { uploadAvatarAndSubmit, isUploading } = useUploadAvatarAndSubmit((fields) => onSubmit( - changedOrNull( - { ...fields, externalResources: { ...definedValues(fields.externalResources) } }, - getUpdateMemberFormInitial(member) - ) + { + ...changedOrNull( + { ...fields, externalResources: { ...definedValues(fields.externalResources) } }, + updateMemberFormInitial + ), + validatorAccounts: isValidator + ? difference(fields.validatorAddresses, updateMemberFormInitial.validatorAddresses) + : [], + validatorAccountsToBeRemoved: isValidator + ? difference(updateMemberFormInitial.validatorAddresses, fields.validatorAddresses) + : updateMemberFormInitial.validatorAddresses, + }, + member.id, + member.controllerAccount ) ) const form = useForm({ resolver: useYupValidationResolver(UpdateMemberSchema), - defaultValues: { - ...getUpdateMemberFormInitial(member), - rootAccount: accountOrNamed(allAccounts, member.rootAccount, 'Root Account'), - controllerAccount: accountOrNamed(allAccounts, member.controllerAccount, 'Controller Account'), - }, context, mode: 'onChange', }) + useEffect(() => { + form.reset({ + ...updateMemberFormInitial, + rootAccount: accountOrNamed(allAccounts, member.rootAccount, 'Root Account'), + controllerAccount: accountOrNamed(allAccounts, member.controllerAccount, 'Controller Account'), + }) + }, [updateMemberFormInitial, member, allAccounts]) + const [controllerAccount, rootAccount, handle] = form.watch(['controllerAccount', 'rootAccount', 'handle']) useEffect(() => { @@ -94,7 +137,26 @@ export const UpdateMembershipFormModal = ({ onClose, onSubmit, member }: Props) const filterRoot = useCallback(filterAccount(controllerAccount), [controllerAccount]) const filterController = useCallback(filterAccount(rootAccount), [rootAccount]) - const canUpdate = form.formState.isValid && hasAnyEdits(form.getValues(), getUpdateMemberFormInitial(member)) + const formData = useMemo( + () => + ({ + ...form.getValues(), + isValidator, + validatorAddresses: validatorAccounts.flatMap((account) => account?.address ?? []), + } as UpdateMemberForm), + [form.getValues(), validatorAccounts] + ) + + const canUpdate = + form.formState.isValid && + hasAnyEdits(formData, updateMemberFormInitial) && + (!isValidator || + (validatorAccounts.length > 0 && validatorAccounts.every((account) => account && isValidatorAccount(account)))) + + const willBecomeUnverifiedValidator = + updateMemberFormInitial.isValidator && hasAnyMetadateChanges(formData, updateMemberFormInitial) + + const submit = () => uploadAvatarAndSubmit(formData as FormFields) return ( @@ -153,6 +215,17 @@ export const UpdateMembershipFormModal = ({ onClose, onSubmit, member }: Props) member.externalResources ? member.externalResources.map((resource) => resource.source) : [] } /> + + {willBecomeUnverifiedValidator && ( + + )} + + @@ -160,7 +233,7 @@ export const UpdateMembershipFormModal = ({ onClose, onSubmit, member }: Props) next={{ disabled: !canUpdate || isUploading, label: isUploading ? : 'Save changes', - onClick: () => uploadAvatarAndSubmit(form.getValues()), + onClick: submit, }} /> diff --git a/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipModal.tsx b/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipModal.tsx index 834bbf1a45..6eef2f5146 100644 --- a/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipModal.tsx +++ b/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipModal.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { useMemo } from 'react' import { useApi } from '@/api/hooks/useApi' import { TextMedium } from '@/common/components/typography' @@ -23,23 +23,102 @@ export const UpdateMembershipModal = () => { } = useModal() const [state, send] = useMachine(updateMembershipMachine) + const unbondValidatorAccTransaction = useMemo( + () => api?.tx.members.removeStakingAccount(member.id), + [api?.isConnected] + ) + + const bondValidatorAccTransaction = useMemo( + () => api?.tx.members.addStakingAccountCandidate(member.id), + [api?.isConnected] + ) + + const conFirmTransaction = useMemo(() => { + const validatorAccounts = state.context.form?.validatorAccounts + + if (!api || !validatorAccounts) return + + const confirmTxs = validatorAccounts.map((address) => api.tx.members.confirmStakingAccount(member.id, address)) + + return confirmTxs.length > 1 ? api.tx.utility.batch(confirmTxs) : confirmTxs[0] + }, [api?.isConnected, state.context.form?.validatorAccounts]) + + const updateMembershipTransaction = useMemo( + () => state.context.form && createBatch(state.context.form, api, member), + [api?.isConnected, state.context.form] + ) + if (state.matches('prepare')) { return ( send('DONE', { form: params })} + onSubmit={(params, memberId, controllerAccount) => send('DONE', { form: params, memberId, controllerAccount })} member={member} /> ) } - if (state.matches('transaction')) { + if (state.matches('removeStakingAccTx') && unbondValidatorAccTransaction) { + if ( + !state.context.form.validatorAccountsToBeRemoved || + state.context.form.validatorAccountsToBeRemoved.length === 0 + ) { + send('SKIP_UNBONDING') + return null + } + return ( + + You intend to remove the validator account from your membership. + + ) + } + + if (state.matches('addStakingAccCandidateTx') && bondValidatorAccTransaction) { + if (!state.context.form.validatorAccounts || state.context.form.validatorAccounts.length === 0) { + send('SKIP_BONDING') + return null + } + return ( + + You intend to to bond new validator account with your membership. + + ) + } + + if (state.matches('confirmStakingAccTx') && conFirmTransaction) { + return ( + + You intend to confirm your validator account to be bound with your membership. + + ) + } + + if (state.matches('updateMembershipTx')) { + if (!updateMembershipTransaction) { + send('SKIP_UPDATE_MEMBERSHIP') + return null + } return ( You intend to update your membership. diff --git a/packages/ui/src/memberships/modals/UpdateMembershipModal/machine.ts b/packages/ui/src/memberships/modals/UpdateMembershipModal/machine.ts index e9f6c27610..0b72671b8f 100644 --- a/packages/ui/src/memberships/modals/UpdateMembershipModal/machine.ts +++ b/packages/ui/src/memberships/modals/UpdateMembershipModal/machine.ts @@ -14,6 +14,8 @@ import { UpdateMemberForm } from './types' interface UpdateMembershipContext { form?: UpdateMemberForm + unbondingValidatorAccStep?: number + bondingValidatorAccStep?: number } interface TransactionContext { @@ -24,7 +26,10 @@ type Context = UpdateMembershipContext & TransactionContext type UpdateMembershipState = | { value: 'prepare'; context: EmptyObject } - | { value: 'transaction'; context: Required } + | { value: 'updateMembershipTx'; context: Required } + | { value: 'removeStakingAccTx'; context: Required } + | { value: 'addStakingAccCandidateTx'; context: Required } + | { value: 'confirmStakingAccTx'; context: Required } | { value: 'success'; context: Required } | { value: 'error'; context: Required } @@ -34,6 +39,20 @@ export type UpdateMembershipEvent = | { type: 'DONE'; form: UpdateMemberForm } | { type: 'SUCCESS' } | { type: 'ERROR' } + | { type: 'SKIP_UPDATE_MEMBERSHIP' } + | { type: 'SKIP_UNBONDING' } + | { type: 'SKIP_BONDING' } + +const isUnbondingStateSelfTransition = (context: Context) => + !!context.form?.validatorAccountsToBeRemoved && + context.form?.validatorAccountsToBeRemoved.length > 1 && + (!context.unbondingValidatorAccStep || + context.form.validatorAccountsToBeRemoved.length - 1 > context.unbondingValidatorAccStep) + +const isBondingStateSelfTransition = (context: Context) => + !!context.form?.validatorAccounts && + context.form?.validatorAccounts.length > 1 && + (!context.bondingValidatorAccStep || context.form.validatorAccounts.length - 1 > context.bondingValidatorAccStep) export const updateMembershipMachine = createMachine({ initial: 'prepare', @@ -41,14 +60,96 @@ export const updateMembershipMachine = createMachine event.form }), + target: 'removeStakingAccTx', + actions: assign({ + form: (_, event) => event.form, + }), }, }, }, - transaction: { + removeStakingAccTx: { + invoke: { + id: 'removeStakingAcc', + src: transactionMachine, + onDone: [ + { + target: 'removeStakingAccTx', + cond: isUnbondingStateSelfTransition, + actions: assign({ + unbondingValidatorAccStep: (context) => (context.unbondingValidatorAccStep ?? 0) + 1, + }), + }, + { + target: 'addStakingAccCandidateTx', + cond: isTransactionSuccess, + }, + { + target: 'error', + cond: isTransactionError, + }, + { + target: 'canceled', + cond: isTransactionCanceled, + }, + ], + }, + on: { + SKIP_UNBONDING: 'addStakingAccCandidateTx', + }, + }, + addStakingAccCandidateTx: { invoke: { - id: 'transaction', + id: 'addStakingAccCandidate', + src: transactionMachine, + onDone: [ + { + target: 'addStakingAccCandidateTx', + cond: isBondingStateSelfTransition, + actions: assign({ + bondingValidatorAccStep: (context) => (context.bondingValidatorAccStep ?? 0) + 1, + }), + }, + { + target: 'confirmStakingAccTx', + cond: isTransactionSuccess, + }, + { + target: 'error', + cond: isTransactionError, + }, + { + target: 'canceled', + cond: isTransactionCanceled, + }, + ], + }, + on: { + SKIP_BONDING: 'updateMembershipTx', + }, + }, + confirmStakingAccTx: { + invoke: { + id: 'confirmStakingAcc', + src: transactionMachine, + onDone: [ + { + target: 'updateMembershipTx', + cond: isTransactionSuccess, + }, + { + target: 'error', + cond: isTransactionError, + }, + { + target: 'canceled', + cond: isTransactionCanceled, + }, + ], + }, + }, + updateMembershipTx: { + invoke: { + id: 'updateMembership', src: transactionMachine, onDone: [ { @@ -65,6 +166,9 @@ export const updateMembershipMachine = createMachine + validatorAccounts?: Address[] + validatorAccountsToBeRemoved?: Address[] } diff --git a/packages/ui/src/memberships/modals/UpdateMembershipModal/utils.ts b/packages/ui/src/memberships/modals/UpdateMembershipModal/utils.ts index 85c8c069ae..2499a0c7d5 100644 --- a/packages/ui/src/memberships/modals/UpdateMembershipModal/utils.ts +++ b/packages/ui/src/memberships/modals/UpdateMembershipModal/utils.ts @@ -13,6 +13,21 @@ export const hasAnyEdits = (form: Record, initial: Record, initial: Record) => { + const metadataFields = ['about', 'avatarUri', 'externalResources', 'validatorAccounts'] + return metadataFields.some((key) => { + const initialValue = initial[key === 'avatarUri' ? 'avatar' : key] || '' + const formValue = form[key] || '' + if (initialValue !== formValue) { + if (key === 'externalResources' || key === 'validatorAccounts') { + return JSON.stringify(initialValue) !== JSON.stringify(formValue) + } + return true + } + return false + }) +} + export const getChangedFields = (form: Record, initial: Record) => { const changedFields = [] @@ -20,7 +35,7 @@ export const getChangedFields = (form: Record, initial: Record[]) => - transactions.forEach((transaction) => transaction.signAndSend('')), + onSend: (signer: string, transactions: SubmittableExtrinsic<'rxjs'>[]) => + transactions.forEach((transaction) => transaction.signAndSend(signer)), }, }, }, diff --git a/packages/ui/src/mocks/data/raw/members.json b/packages/ui/src/mocks/data/raw/members.json index 5047f572dc..f8056cbd09 100644 --- a/packages/ui/src/mocks/data/raw/members.json +++ b/packages/ui/src/mocks/data/raw/members.json @@ -6,7 +6,8 @@ "boundAccounts": [ "j4W7rVcUCxi2crhhjRq46fNDRbVHTjJrz6bKxZwehEMQxZeSf", "j4VdDQVdwFYfQ2MvEdLT2EYZx4ALPQQ6yMyZopKoZEQmXcJrT", - "j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW" + "j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW", + "j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz" ], "boundAccountsEvents": [], "handle": "alice", @@ -31,9 +32,9 @@ { "id": "1", "rootAccount": "j4X5AiyNC4497MpJLtyGdgEAS4JjDEjkRvtUPgZkiYudW5zox", - "controllerAccount": "j4UYhDYJ4pz2ihhDDzu69v2JTVeGaGmTebmBdWaX2ANVinXyE", + "controllerAccount": "j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW", "boundAccounts": [ - "j4UYhDYJ4pz2ihhDDzu69v2JTVeGaGmTebmBdWaX2ANVinXyE", + "j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW", "j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP" ], "boundAccountsEvents": [], @@ -41,7 +42,7 @@ "metadata": { "name": "soluta voluptas", "about": "Nulla corporis quo dolorem. Sint quia dignissimos. Et sit dolore fuga in alias voluptatibus. Natus repudiandae debitis exercitationem quasi aut. Alias in expedita aut provident in quia quod doloremque ipsum.\n \rDistinctio provident pariatur alias ut hic ipsa eum doloremque. Harum ipsum quae aut quod consequatur laboriosam delectus minus aut. Ea eaque ducimus. Ut quia ex molestiae et.\n \rVoluptatibus rem perferendis molestiae. Quia quibusdam optio quo sed error voluptas occaecati. Ratione voluptates consequatur rerum numquam. Voluptas dignissimos soluta voluptatem reprehenderit ea fuga et inventore. Blanditiis amet quasi nihil et ad rerum molestiae sed officiis.", - "isVerifiedValidator": false + "isVerifiedValidator": true }, "isVerified": true, "isFoundingMember": true, @@ -59,7 +60,7 @@ { "id": "2", "rootAccount": "j4UbMHiS79yvMLJctXggUugkkKmwxG5LW2YSy3ap8SmgF5qW9", - "controllerAccount": "j4UbMHiS79yvMLJctXggUugkkKmwxG5LW2YSy3ap8SmgF5qW9", + "controllerAccount": "j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz", "boundAccounts": ["j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz"], "boundAccountsEvents": [], "handle": "charlie", @@ -91,7 +92,7 @@ "metadata": { "name": "architecto deleniti", "about": "Cumque vero a aspernatur esse optio. Libero saepe voluptatem est est qui. Velit quidem sed. Aliquid temporibus id ea aut vel qui recusandae magni. Aliquam cupiditate cupiditate aut doloribus sit nemo. Aliquid ut iure ullam non.", - "isVerifiedValidator": true + "isVerifiedValidator": false }, "isVerified": true, "isFoundingMember": true, diff --git a/packages/ui/src/mocks/data/validators.ts b/packages/ui/src/mocks/data/validators.ts new file mode 100644 index 0000000000..0913ecaba9 --- /dev/null +++ b/packages/ui/src/mocks/data/validators.ts @@ -0,0 +1,76 @@ +import { joy } from '../helpers' + +export const validators = { + j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: { + commission: 0.05 * 10 ** 9, + totalStake: joy(400), + ownStake: joy(0.0001), + nominators: { + j4WGdFxqTkyAgzJiTbEBeRseP12dPEvJgf2Wy9qkPa68XSP55: { stake: joy(0.2) }, + j4UQEfPFnKwGuHytxs9YEouLnhnSNkPDgNm9tKeB7an3dRaiy: { stake: joy(0.2) }, + }, + }, + j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW: { + commission: 0.1 * 10 ** 9, + totalStake: joy(400), + ownStake: joy(0.0001), + nominators: { + j4T3XgRMUaZZL6GsMk6RXfBcjuMWxfSLnoATYkBTHh7xyjmoH: { stake: joy(0.2) }, + j4UQEfPFnKwGuHytxs9YEouLnhnSNkPDgNm9tKeB7an3dRaiy: { stake: joy(0.2) }, + j4W2bw7ggG69e9TZ77RP9mjem1GrbPwpbKYK7WdZiym77yzMJ: { stake: joy(0.2) }, + j4UzoJUhDGpnsCWrmx9ojofwaT8KHz3azp8C1S49MSN6rYjim: { stake: joy(0.2) }, + }, + }, + j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP: { + commission: 0.05 * 10 ** 9, + totalStake: joy(400), + ownStake: joy(0.0001), + nominators: { + j4SgrgDrzzGyfrxPe4ZgaKfByKyLo5SdsUXNfHzZJPh5R6f8q: { stake: joy(0.2) }, + j4UQEfPFnKwGuHytxs9YEouLnhnSNkPDgNm9tKeB7an3dRaiy: { stake: joy(0.2) }, + j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D: { stake: joy(0.2) }, + j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: { stake: joy(0.2) }, + }, + }, + j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz: { + commission: 0.15 * 10 ** 9, + totalStake: joy(400), + ownStake: joy(0.0001), + nominators: { + j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: { stake: joy(0.2) }, + j4UQEfPFnKwGuHytxs9YEouLnhnSNkPDgNm9tKeB7an3dRaiy: { stake: joy(0.2) }, + j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: { stake: joy(0.2) }, + }, + }, + j4SR5Mty5Mzy2dPTunA6TD4gBTwbSb8wRTabvu2gsLqC271d4: { + commission: 0.2 * 10 ** 9, + totalStake: joy(400), + ownStake: joy(0.0001), + nominators: { + j4WwTZ3fnkoXJw3D1vGVyymjaiLxM78TGyAAX41JRH8Kx6T2u: { stake: joy(0.2) }, + j4UQEfPFnKwGuHytxs9YEouLnhnSNkPDgNm9tKeB7an3dRaiy: { stake: joy(0.2) }, + j4WqZwj6KjB4DbxknxyJB1ZkeVrPRGmg6DUGw2YkuAy7jUERg: { stake: joy(0.2) }, + j4Wo9377XBAvhmB35J4TkpJUHnUKmyccXhGtHCVvi6pPr9so8: { stake: joy(0.2) }, + }, + }, + j4WXe5CtD6NkEM1KUXP5BLB4sTN77PFL4edS3c7eXAAHP83aF: { + commission: 0.01 * 10 ** 9, + totalStake: joy(400), + ownStake: joy(0.0001), + }, + j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP: { + commission: 0.03 * 10 ** 9, + totalStake: joy(400), + ownStake: joy(0.0001), + }, + j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ: { + commission: 0.05 * 10 ** 9, + totalStake: joy(400), + ownStake: joy(0.0001), + }, + j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg: { + commission: 0.05 * 10 ** 9, + totalStake: joy(400), + ownStake: joy(0.0001), + }, +} diff --git a/packages/ui/src/mocks/helpers/asChainData.ts b/packages/ui/src/mocks/helpers/asChainData.ts index d0d3780675..07b92db915 100644 --- a/packages/ui/src/mocks/helpers/asChainData.ts +++ b/packages/ui/src/mocks/helpers/asChainData.ts @@ -1,12 +1,36 @@ import { createType } from '@joystream/types' +import { isAddress } from '@polkadot/util-crypto' import { mapValues } from 'lodash' import { encodeAddress } from '@/accounts/model/encodeAddress' -export const asChainData = (data: any): any => { +const mockApiMethods = (mapFn: (data: any) => any) => (_data: any) => { + const data = mapFn(_data) + if (!data || typeof data !== 'object') return data + + try { + return Object.defineProperties(data, { + unwrap: { value: () => data }, + toJSON: { value: () => data }, + isSome: { value: Object.keys(data).length > 0 }, + get: { + value: (key: any) => { + if (key.toRawType?.() === 'AccountId') { + return data[encodeAddress(key.toString())] + } + return data[key.toString()] + }, + }, + }) + } catch { + return data + } +} + +export const asChainData = mockApiMethods((data: any): any => { switch (Object.getPrototypeOf(data).constructor.name) { case 'Object': - return withUnwrap(mapValues(data, asChainData)) + return mapValues(data, asChainData) case 'Array': return data.map(asChainData) @@ -15,23 +39,11 @@ export const asChainData = (data: any): any => { return createType('u128', data) case 'String': - return isNaN(data) ? data : createType('u128', data) + if (!isNaN(data)) return createType('u128', data) + if (isAddress(data)) return createType('AccountId', data) + return createType('Text', data) default: return data } -} - -const withUnwrap = (data: Record) => - Object.defineProperties(data, { - unwrap: { value: () => data }, - isSome: { value: Object.keys(data).length > 0 }, - get: { - value: (key: any) => { - if (key.toRawType?.() === 'AccountId') { - return data[encodeAddress(key.toString())] - } - return data[key.toString()] - }, - }, - }) +}) diff --git a/packages/ui/src/mocks/helpers/transactions.ts b/packages/ui/src/mocks/helpers/transactions.ts index 71df12902e..94283dfcb4 100644 --- a/packages/ui/src/mocks/helpers/transactions.ts +++ b/packages/ui/src/mocks/helpers/transactions.ts @@ -33,8 +33,8 @@ export const fromTxMock = ( onCall?.(...args) return { paymentInfo, - signAndSend: () => { - onSend?.(...args) + signAndSend: (signer: string) => { + onSend?.(signer, ...args) return txResult }, } diff --git a/packages/ui/src/mocks/providers/api.tsx b/packages/ui/src/mocks/providers/api.tsx index cf5257832d..5474273c2b 100644 --- a/packages/ui/src/mocks/providers/api.tsx +++ b/packages/ui/src/mocks/providers/api.tsx @@ -40,6 +40,13 @@ export const MockApiProvider: FC = ({ children, chain }) => { if (!chain) return // Common mocks: + const defaultDerive = { + staking: { erasRewards: [], erasPoints: [] }, + } + const defaultQuery = { + session: { validators: [] }, + staking: { activeEra: {} }, + } const rpcChain = { getBlockHash: createType('BlockHash', BLOCK_HASH), getHeader: { @@ -58,8 +65,8 @@ export const MockApiProvider: FC = ({ children, chain }) => { _async: { chainMetadata: Promise.resolve({}) } as Api['_async'], isConnected: true, consts: asApi('consts', asApiConst), - derive: asApi('derive', asApiMethod), - query: asApi('query', asApiMethod), + derive: asApi('derive', asApiMethod, defaultDerive), + query: asApi('query', asApiMethod, defaultQuery), rpc: asApi('rpc', asApiMethod, { chain: rpcChain }), tx: asApi('tx', fromTxMock), } @@ -120,9 +127,24 @@ const asApiMethod = (value: any) => { if (isFunction(value)) { type ArgumentsType = T extends (...args: infer A) => any ? A : never type FunctionArgs = ArgumentsType - return (args: FunctionArgs) => of(asChainData(value(args))) + return (...args: FunctionArgs) => of(asChainData(value(...args))) } else if (value instanceof Observable) { return () => value + } else if (value instanceof Map) { + return Object.defineProperties( + (key: Parameters<(typeof value)['get']>[0]) => { + switch (typeof value.keys().next().value) { + case 'string': + return of(asChainData(value.get(String(key)))) + case 'number': + return of(asChainData(value.get(Number(key)))) + } + }, + { + size: { value: () => of(asChainData(value.size)) }, + entries: { value: () => of(Array.from(asChainData(value.entries()))) }, + } + ) } const method = () => of(asChainData(value)) @@ -141,8 +163,7 @@ const asApiMethod = (value: any) => { } if (isObject(value) && 'multi' in value && isArray(value.multi)) { - const multi = value.multi.map((entry) => ({ unwrap: () => entry })) - method.multi = () => of(multi) + method.multi = () => of(asChainData(value.multi)) } return method diff --git a/packages/ui/src/proxyApi/client/query.ts b/packages/ui/src/proxyApi/client/query.ts index ac57670e57..e67d50b634 100644 --- a/packages/ui/src/proxyApi/client/query.ts +++ b/packages/ui/src/proxyApi/client/query.ts @@ -1,6 +1,6 @@ import { AnyTuple } from '@polkadot/types/types' import { uniqueId } from 'lodash' -import { filter, Observable, map, share } from 'rxjs' +import { filter, Observable, map } from 'rxjs' import { deserializeMessage } from '../models/payload' import { ApiKinds, PostMessage, RawWorkerMessageEvent } from '../types' @@ -35,25 +35,29 @@ export const query = ( ) => { const queryMessages = messages.pipe( filter(({ data }) => data.messageType === apiKind), - deserializeMessage>(), - share() + deserializeMessage>() ) - return apiInterfaceProxy((module, ...path) => (...params) => { - const callId = uniqueId(`${apiKind}.${String(module)}.${path.join('.')}.`) - - postMessage({ - messageType: apiKind, - module, - path, - callId, - payload: params, - } as ClientQueryMessage) - - return queryMessages.pipe( - filter((message) => message.callId === callId), - map(({ payload }) => payload), - share() - ) - }) + return apiInterfaceProxy( + (module, ...path) => + (...params) => + new Observable((subscriber) => { + const callId = uniqueId(`${apiKind}.${String(module)}.${path.join('.')}.`) + + postMessage({ + messageType: apiKind, + module, + path, + callId, + payload: params, + } as ClientQueryMessage) + + return queryMessages + .pipe( + filter((message) => message.callId === callId), + map((message) => message.payload) + ) + .subscribe((value) => subscriber.next(value)) + }) + ) } diff --git a/packages/ui/src/proxyApi/client/tx.ts b/packages/ui/src/proxyApi/client/tx.ts index 745bbaa858..3bf70b496b 100644 --- a/packages/ui/src/proxyApi/client/tx.ts +++ b/packages/ui/src/proxyApi/client/tx.ts @@ -62,21 +62,25 @@ export const tx = (messages: Observable, postMessage: Pos function addObservableMethodEntry(method: ObservableMethods) { return [ method, - (...params: AnyTuple) => { - const callId = uniqueId(`tx.${module}.${txKey}.${method}.`) - _postMessage({ method: { key: method, id: callId }, payload: params }) - return _messages.pipe( - filter((message) => message.callId === callId), - map(({ payload: { error, result } }) => { - if (error) { - throw error - } else { - return result - } - }), - share() - ) - }, + (...params: AnyTuple) => + new Observable((subscriber) => { + const callId = uniqueId(`tx.${module}.${txKey}.${method}.`) + + _postMessage({ method: { key: method, id: callId }, payload: params }) + + _messages + .pipe( + filter((message) => message.callId === callId), + map(({ payload: { error, result } }) => { + if (error) { + throw error + } else { + return result + } + }) + ) + .subscribe((value) => subscriber.next(value)) + }), ] } }) diff --git a/packages/ui/src/validators/components/ValidatorInfo.tsx b/packages/ui/src/validators/components/ValidatorInfo.tsx index 7a419e19b6..2a97a731dc 100644 --- a/packages/ui/src/validators/components/ValidatorInfo.tsx +++ b/packages/ui/src/validators/components/ValidatorInfo.tsx @@ -4,12 +4,14 @@ import styled from 'styled-components' import { CopyComponent } from '@/common/components/CopyComponent' import { DiscordIcon, TelegramIcon, TwitterIcon } from '@/common/components/icons/socials' +import { Link } from '@/common/components/Link' import { DefaultTooltip, Tooltip } from '@/common/components/Tooltip' import { BorderRad, Colors, Transitions } from '@/common/constants' import { shortenAddress } from '@/common/model/formatters' import { Address } from '@/common/types' import { MemberIcons } from '@/memberships/components' import { Avatar } from '@/memberships/components/Avatar' +import { ExternalResourceLink } from '@/memberships/constants' import { MemberWithDetails } from '@/memberships/types' interface ValidatorInfoProps { @@ -39,18 +41,18 @@ export const ValidatorInfo = React.memo(({ address, member, size = 's' }: Valida {(twitter || telegram || discord) && ( {twitter && ( - + e.stopPropagation()} href={`${ExternalResourceLink.TWITTER}${twitter.value}`}> - + )} {telegram && ( - + e.stopPropagation()} href={`${ExternalResourceLink.TELEGRAM}${telegram.value}`}> - + )} {discord && ( diff --git a/packages/ui/src/validators/components/ValidatorItem.tsx b/packages/ui/src/validators/components/ValidatorItem.tsx index c09e648a1d..bee74eaf02 100644 --- a/packages/ui/src/validators/components/ValidatorItem.tsx +++ b/packages/ui/src/validators/components/ValidatorItem.tsx @@ -9,6 +9,7 @@ import { Skeleton } from '@/common/components/Skeleton' import { TextMedium, TokenValue } from '@/common/components/typography' import { BorderRad, Colors, Sizes, Transitions } from '@/common/constants' import { useModal } from '@/common/hooks/useModal' +import { whenDefined } from '@/common/utils' import { NominatingRedirectModalCall } from '../modals/NominatingRedirectModal' import { ValidatorWithDetails } from '../types/Validator' @@ -37,9 +38,9 @@ export const ValidatorItem = ({ validator, onClick }: ValidatorItemProps) => { {isActive ? 'active' : 'waiting'} - - - {APR}% + + + {whenDefined(APR, (apr) => `${apr}%`) ?? '-'} {commission}% ( + + + + + + + + + + + + + + + +)) diff --git a/packages/ui/src/validators/components/ValidatorsFilter.tsx b/packages/ui/src/validators/components/ValidatorsFilter.tsx index 33e9987629..7b046d63ec 100644 --- a/packages/ui/src/validators/components/ValidatorsFilter.tsx +++ b/packages/ui/src/validators/components/ValidatorsFilter.tsx @@ -1,20 +1,19 @@ import React, { useEffect, useState } from 'react' import styled from 'styled-components' -import { FilterBox } from '@/common/components/forms/FilterBox' +import { InputNotification } from '@/common/components/forms' +import { Fields, FilterBox } from '@/common/components/forms/FilterBox' import { SearchBox } from '@/common/components/forms/FilterBox/FilterSearchBox' import { FilterSelect } from '@/common/components/selects' -import { Verification, State } from '../types' - interface ValidatorFilterProps { filter: { search: string setSearch: (search: string) => void - isVerified: Verification - setIsVerified: (isVerified: Verification) => void - isActive: State - setIsActive: (isActive: State) => void + isVerified: boolean | undefined + setIsVerified: (isVerified: boolean | undefined) => void + isActive: boolean | undefined + setIsActive: (isActive: boolean | undefined) => void } } @@ -24,38 +23,49 @@ export const ValidatorsFilter = ({ filter }: ValidatorFilterProps) => { setSearch(filter.search) }, [filter.search]) const display = () => filter.setSearch(search) + + const { isVerified, isActive } = filter + const verificationValue = isVerified === true ? 'verified' : isVerified === false ? 'unverified' : null + const stateValue = isActive === true ? 'active' : isActive === false ? 'waiting' : null + const clear = - filter.search || filter.isVerified || filter.isActive + filter.search || verificationValue || stateValue ? () => { filter.setSearch('') - filter.setIsVerified(null) - filter.setIsActive(null) + filter.setIsVerified(undefined) + filter.setIsActive(undefined) } : undefined return ( - - + + filter.setIsVerified(value === null ? undefined : value === 'verified')} /> filter.setIsActive(value === null ? undefined : value === 'active')} /> - - + + ) } +const ValidatorFilterBox = styled(FilterBox)` + ${Fields} { + padding-bottom: 22px; + } +` + const SelectFields = styled.div` display: flex; justify-content: flex-start; @@ -63,9 +73,25 @@ const SelectFields = styled.div` * { width: 184px; } + + @media (max-width: 767px) { + flex-direction: column; + * { + width: 100%; + } + } ` -const Fields = styled.div` +const ResponsiveWrapper = styled.div` display: flex; justify-content: space-between; gap: 8px; + + ${InputNotification} { + top: unset; + bottom: 2px; + } + + @media (max-width: 767px) { + flex-direction: column; + } ` diff --git a/packages/ui/src/validators/components/ValidatorsList.tsx b/packages/ui/src/validators/components/ValidatorsList.tsx index 197496f643..a45eeaa3b4 100644 --- a/packages/ui/src/validators/components/ValidatorsList.tsx +++ b/packages/ui/src/validators/components/ValidatorsList.tsx @@ -1,117 +1,151 @@ -import React, { useMemo, useState } from 'react' +import React, { useState } from 'react' +import { useTranslation } from 'react-i18next' import { generatePath } from 'react-router-dom' import styled from 'styled-components' import { List, ListItem } from '@/common/components/List' import { ListHeader } from '@/common/components/List/ListHeader' import { SortHeader } from '@/common/components/List/SortHeader' +import { Pagination, PaginationProps } from '@/common/components/Pagination' import { Tooltip, TooltipDefault } from '@/common/components/Tooltip' -import { Colors } from '@/common/constants' -import { Comparator } from '@/common/model/Comparator' +import { NotFoundText } from '@/common/components/typography/NotFoundText' +import { BreakPoints, Colors } from '@/common/constants' import { WorkingGroupsRoutes } from '@/working-groups/constants' import { ValidatorCard } from '../modals/validatorCard/ValidatorCard' -import { ValidatorWithDetails } from '../types' +import { ValidatorDetailsOrder, ValidatorWithDetails } from '../types' import { ValidatorItem } from './ValidatorItem' +import { ValidatorItemLoading } from './ValidatorItemLoading' interface ValidatorsListProps { - validators: ValidatorWithDetails[] + validators: ValidatorWithDetails[] | undefined + eraIndex: number | undefined + order: ValidatorDetailsOrder & { sortBy: (key: ValidatorDetailsOrder['key']) => () => void } + pagination: PaginationProps } -export const ValidatorsList = ({ validators }: ValidatorsListProps) => { +export const ValidatorsList = ({ validators, eraIndex, order, pagination }: ValidatorsListProps) => { + const { t } = useTranslation('validators') const [cardNumber, selectCard] = useState(null) - type SortKey = 'stashAccount' | 'APR' | 'commission' - const [sortBy, setSortBy] = useState('stashAccount') - const [isDescending, setDescending] = useState(false) - const sortedValidators = useMemo( - () => - [...validators].sort( - Comparator(isDescending, sortBy)[sortBy === 'stashAccount' ? 'string' : 'number'] - ), - [sortBy, isDescending, validators] - ) - - const onSort = (key: SortKey, descendingByDefault = false) => { - if (key === sortBy) { - setDescending(!isDescending) - } else { - setDescending(descendingByDefault) - setSortBy(key) - } - } + if (validators && !validators.length) return {t('common:forms.noResults')} return ( - - - onSort('stashAccount')} - isActive={sortBy === 'stashAccount'} - isDescending={isDescending} - > - Validator - - - Verification - - - - - State - Own Stake - Total Stake - onSort('APR', true)} isActive={sortBy === 'APR'} isDescending={isDescending}> - Expected Nom APR - - This column shows the expected APR for nominators who are nominating funds for the chosen validator. The - APR is subject to the amount staked and have a diminishing return for higher token amounts. This is - calculated as follow: Last reward extrapolated over a year times{' '} - The nominator commission divided by The total staked by the validator -

- } - > - -
-
- onSort('commission', true)} - isActive={sortBy === 'commission'} - isDescending={isDescending} - > - Commission - -
- - {sortedValidators?.map((validator, index) => ( - { - selectCard(index + 1) - }} - > - - - ))} - - {cardNumber && sortedValidators[cardNumber - 1] && ( - - )} -
+ + + + + + Validator + + + Verification + + + + + State + Own Stake + Total Stake + + Expected Nom APR + + This column shows the expected APR for nominators who are nominating funds for the chosen validator. + The APR is subject to the amount staked and have a diminishing return for higher token amounts. This + is calculated as follow: +
+ Yearly Reward * (1 - Commission) / Stake +
+
Reward:
+
Average reward generated (during the last 30 days) extrapolated over a year.
+ +
Commission:
+
Current nominator commission.
+ +
Stake:
+
Current total stake (validator + nominators).
+
+

+ } + > + +
+
+ + Commission + The validator commission on the nominators rewards

}> + +
+
+
+ {!validators ? ( + + ) : ( + <> + + {validators?.map((validator, index) => ( + { + selectCard(index + 1) + }} + > + + + ))} + + {cardNumber && validators[cardNumber - 1] && ( + + )} + + )} +
+
+ +
) } +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + align-items: end; +` + +const ResponsiveWrap = styled.div` + overflow: auto; + align-self: stretch; + max-width: calc(100vw - 32px); + @media (min-width: ${BreakPoints.sm}px) { + max-width: calc(100vw - 48px); + } + @media (min-width: ${BreakPoints.md}px) { + max-width: calc(100vw - 274px); + } +` + const ValidatorsListWrap = styled.div` display: grid; grid-template-columns: 1fr; @@ -120,7 +154,7 @@ const ValidatorsListWrap = styled.div` 'validatorstablenav' 'validatorslist'; grid-row-gap: 4px; - width: 100%; + min-width: 1166px; ${List} { gap: 8px; @@ -134,7 +168,7 @@ const ListHeaders = styled.div` display: grid; grid-area: validatorstablenav; grid-template-rows: 1fr; - grid-template-columns: 250px 100px 80px 120px 120px 140px 100px 90px; + grid-template-columns: 250px 110px 80px 140px 140px 140px 100px 90px; justify-content: space-between; width: 100%; padding: 0 16px; diff --git a/packages/ui/src/validators/components/statistics/Era.tsx b/packages/ui/src/validators/components/statistics/Era.tsx index f27841d7a3..10f52d7304 100644 --- a/packages/ui/src/validators/components/statistics/Era.tsx +++ b/packages/ui/src/validators/components/statistics/Era.tsx @@ -1,6 +1,4 @@ -import { Option, u64 } from '@polkadot/types' -import { PalletStakingEraRewardPoints } from '@polkadot/types/lookup' -import React, { useMemo } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { PercentageChart } from '@/common/components/charts/PercentageChart' import { BlockIcon } from '@/common/components/icons' @@ -12,50 +10,57 @@ import { formatDurationDate, } from '@/common/components/statistics' import { DurationValue } from '@/common/components/typography/DurationValue' +import { ERA_DURATION } from '@/common/constants' +import { MILLISECONDS_PER_BLOCK } from '@/common/model/formatters' +import { isDefined, whenDefined } from '@/common/utils' interface EraProps { - eraStartedOn: Option | undefined - eraDuration: number - now: u64 | undefined - eraRewardPoints: PalletStakingEraRewardPoints | undefined + eraStartedOn: number | undefined } -export const Era = ({ eraStartedOn, eraDuration, now, eraRewardPoints }: EraProps) => { - const { nextReward, percentage } = useMemo(() => { - const nextReward = now && eraStartedOn && eraDuration - (Number(now) - Number(eraStartedOn)) - const totalDuration = Number(eraDuration) - const percentage = nextReward ? Math.ceil(100 - (nextReward / totalDuration) * 100) : 0 - return { - nextReward: formatDurationDate(nextReward ?? 0), - totalDuration: formatDurationDate(totalDuration ?? 0), - percentage, - } - }, [eraStartedOn, eraDuration, now]) +const POINTS_PER_BLOCK = 20 + +export const Era = ({ eraStartedOn }: EraProps) => { + const [spentDuration, setSpentDuration] = useState() + + const { nextReward, percentage, blocks } = useMemo( + () => ({ + nextReward: whenDefined(spentDuration, (d) => ERA_DURATION - d), + percentage: spentDuration && Math.ceil((100 * spentDuration) / ERA_DURATION), + blocks: spentDuration && Math.floor(spentDuration / MILLISECONDS_PER_BLOCK), + }), + [spentDuration] + ) + + useEffect(() => { + if (!eraStartedOn) return + const interval = setInterval(() => setSpentDuration(Math.max(0, Date.now() - Number(eraStartedOn))), 1000) + return () => clearInterval(interval) + }, [eraStartedOn]) + return ( } + actionElement={isDefined(percentage) && } > Next Reward -
- -
+
{isDefined(nextReward) && }
Blocks / Points -
- {eraRewardPoints && ( - - - {eraRewardPoints.total.toNumber() / 20} / {eraRewardPoints?.total.toNumber()} - + + {blocks ? ( + <> + {blocks} / {blocks * POINTS_PER_BLOCK} + + ) : ( + '- / -' )} -
+
) diff --git a/packages/ui/src/validators/components/statistics/Rewards.tsx b/packages/ui/src/validators/components/statistics/Rewards.tsx index 7e66d5b6dc..94d0d97c5b 100644 --- a/packages/ui/src/validators/components/statistics/Rewards.tsx +++ b/packages/ui/src/validators/components/statistics/Rewards.tsx @@ -23,7 +23,7 @@ export const Rewards = ({ totalRewards, lastRewards }: RewardsProps) => { - Last + Last Era diff --git a/packages/ui/src/validators/components/statistics/Staking.tsx b/packages/ui/src/validators/components/statistics/Staking.tsx index 319a042c3b..204771cac8 100644 --- a/packages/ui/src/validators/components/statistics/Staking.tsx +++ b/packages/ui/src/validators/components/statistics/Staking.tsx @@ -20,10 +20,10 @@ export const Staking = ({ idealStaking, currentStaking, stakingPercentage }: Sta return ( diff --git a/packages/ui/src/validators/components/statistics/ValidatorsState.tsx b/packages/ui/src/validators/components/statistics/ValidatorsState.tsx index 6bf3c35a26..351a7e00bd 100644 --- a/packages/ui/src/validators/components/statistics/ValidatorsState.tsx +++ b/packages/ui/src/validators/components/statistics/ValidatorsState.tsx @@ -5,34 +5,32 @@ import { NumericValue, StatisticItem, StatisticItemSpacedContent, StatisticLabel interface ValidatorsStateProps { activeValidatorsCount: number allValidatorsCount: number - acitveNominatorsCount: number + activeNominatorsCount: number allNominatorsCount: number } export const ValidatorsState = ({ activeValidatorsCount, allValidatorsCount, - acitveNominatorsCount, + activeNominatorsCount, allNominatorsCount, }: ValidatorsStateProps) => { return ( - + Validator (Active / Waiting) - {activeValidatorsCount} / {allValidatorsCount - activeValidatorsCount} + {`${activeValidatorsCount > 0 ? activeValidatorsCount : '-'} / ${ + allValidatorsCount && activeValidatorsCount ? allValidatorsCount - activeValidatorsCount : '-' + }`} Nominator (Active / Total) - {acitveNominatorsCount} / {allNominatorsCount} + {`${activeNominatorsCount > 0 ? activeNominatorsCount : '-'} / ${ + activeNominatorsCount && allNominatorsCount ? allNominatorsCount : '-' + }`} diff --git a/packages/ui/src/validators/constants/routes.tsx b/packages/ui/src/validators/constants/routes.tsx new file mode 100644 index 0000000000..3c6abea0e2 --- /dev/null +++ b/packages/ui/src/validators/constants/routes.tsx @@ -0,0 +1,10 @@ +export const ValidatorsRoutes = { + list: '/validators', +} as const + +type ValidatorsRoutesType = typeof ValidatorsRoutes + +declare module '@/app/constants/routes' { + // eslint-disable-next-line @typescript-eslint/no-empty-interface + interface Routes extends ValidatorsRoutesType {} +} diff --git a/packages/ui/src/validators/hooks/useStakingStatistics.tsx b/packages/ui/src/validators/hooks/useStakingStatistics.tsx index c10e0b45aa..ae7eac08fa 100644 --- a/packages/ui/src/validators/hooks/useStakingStatistics.tsx +++ b/packages/ui/src/validators/hooks/useStakingStatistics.tsx @@ -1,72 +1,69 @@ -import { BN } from '@polkadot/util' import { useMemo } from 'react' -import { combineLatest, map } from 'rxjs' +import { combineLatest, map, switchMap } from 'rxjs' import { useApi } from '@/api/hooks/useApi' -import { ERA_DURATION } from '@/common/constants' +import { BN_ZERO, ERA_PER_MONTH } from '@/common/constants' import { useObservable } from '@/common/hooks/useObservable' -export const useStakingStatistics = () => { +import { CommonValidatorsQueries } from '../providers/useValidatorsQueries' + +export const useStakingStatistics = ({ + activeEra$, + activeValidators$, + stakers$, + validatorsRewards$, +}: Partial = {}) => { const { api } = useApi() - const activeEra = useObservable( - () => - api?.query.staking.activeEra().pipe( - map((activeEra) => ({ - eraIndex: activeEra?.unwrap().index, - eraStartedOn: activeEra?.unwrap().start, - })) - ), - [api?.isConnected] - ) - const now = useObservable(() => api?.query.timestamp.now(), [api?.isConnected]) + const activeEra = useObservable(() => { + if (!api || !activeEra$) return + + return activeEra$.pipe( + switchMap((era) => api.query.staking.erasTotalStake(era.index).pipe(map((eraStake) => ({ ...era, eraStake })))) + ) + }, [api?.isConnected, activeEra$]) + const totalIssuance = useObservable(() => api?.query.balances.totalIssuance(), [api?.isConnected]) - const currentStaking = useObservable( - () => activeEra && api && api.query.staking.erasTotalStake(activeEra.eraIndex), - [activeEra, api?.isConnected] - ) - const activeValidators = useObservable(() => api?.query.session.validators(), [api?.isConnected]) - const stakers = useObservable( + + const allNominatorsCount = useObservable( + () => api?.query.staking.counterForNominators(), + [api?.isConnected] + )?.toNumber() + + const stakingPercentage = useMemo(() => { + const stake = activeEra?.eraStake + if (!stake || !totalIssuance?.gtn(0)) return 0 + return stake.muln(1000).div(totalIssuance).toNumber() / 10 + }, [activeEra?.eraStake, totalIssuance]) + + const activeValidatorsCount = useObservable(() => activeValidators$, [activeValidators$])?.length ?? 0 + + const activeNominatorsCount = useObservable( () => - activeValidators && - api && - activeEra && - combineLatest(activeValidators.map((address) => api.query.staking.erasStakers(activeEra.eraIndex, address))), - [api?.isConnected, activeValidators, activeEra] - ) - const acitveNominators = useMemo(() => { - const nominators = stakers?.map((validator) => validator.others.map((nominator) => nominator.who.toString())) - const uniqueNominators = [...new Set(nominators?.flat())] - return uniqueNominators - }, [stakers]) - const allValidatorsCount = useObservable(() => api?.query.staking.counterForValidators(), [api?.isConnected]) - const allNominatorsCount = useObservable(() => api?.query.staking.counterForNominators(), [api?.isConnected]) - const lastValidatorRewards = useObservable( - () => activeEra && api && api.query.staking.erasValidatorReward(activeEra.eraIndex.subn(1)), - [activeEra, api?.isConnected] - ) - const totalRewards = useObservable(() => api?.derive.staking.erasRewards(), [api?.isConnected]) - const stakingPercentage = useMemo( - () => (totalIssuance && currentStaking ? currentStaking.muln(1000).div(totalIssuance).toNumber() / 10 : 0), - [currentStaking, totalIssuance] - ) - const eraRewardPoints = useObservable( - () => activeEra && api && api.query.staking.erasRewardPoints(activeEra.eraIndex), - [activeEra, api?.isConnected] + stakers$?.pipe( + switchMap((stakers) => combineLatest(Array.from(stakers.values()))), + map((stakers) => { + const nominators = stakers.flatMap((staker) => staker.others.map((nominator) => nominator.who.toString())) + return new Set(nominators).size + }) + ), + [stakers$] ) + + const validatorsRewards = useObservable(() => validatorsRewards$, [validatorsRewards$]) + return { - eraStartedOn: activeEra?.eraStartedOn, - eraDuration: ERA_DURATION, - eraRewardPoints, - now, - idealStaking: new BN(totalIssuance ?? 0).divn(2), - currentStaking: new BN(currentStaking ?? 0), + eraIndex: activeEra?.index, + eraStartedOn: activeEra?.startedOn, + eraStake: activeEra?.eraStake, + idealStaking: totalIssuance?.divn(2), stakingPercentage, - activeValidatorsCount: activeValidators?.length ?? 0, - acitveNominatorsCount: acitveNominators.length, - allValidatorsCount: allValidatorsCount?.toNumber() ?? 0, - allNominatorsCount: allNominatorsCount?.toNumber() ?? 0, - totalRewards: totalRewards?.reduce((total: BN, reward) => total.add(reward.eraReward), new BN(0)), - lastRewards: new BN(lastValidatorRewards?.toString() ?? 0), + activeValidatorsCount, + activeNominatorsCount, + allNominatorsCount, + totalRewards: validatorsRewards + ?.slice(-ERA_PER_MONTH) // Make it explicit that it's per month + .reduce((sum, { totalReward }) => sum.add(totalReward), BN_ZERO), + lastRewards: validatorsRewards?.at(-1)?.totalReward, } } diff --git a/packages/ui/src/validators/hooks/useValidatorMembers.tsx b/packages/ui/src/validators/hooks/useValidatorMembers.tsx deleted file mode 100644 index 2f6730552d..0000000000 --- a/packages/ui/src/validators/hooks/useValidatorMembers.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useMemo } from 'react' -import { map } from 'rxjs' - -import { useApi } from '@/api/hooks/useApi' -import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' -import { perbillToPercent } from '@/common/utils' -import { useGetMembersWithDetailsQuery } from '@/memberships/queries' -import { asMemberWithDetails } from '@/memberships/types' - -import { ValidatorMembership } from '../types' - -export const useValidatorMembers = () => { - const { api } = useApi() - const allValidators = useFirstObservableValue( - () => - api?.query.staking.validators.entries().pipe( - map((entries) => - entries.map((entry) => ({ - address: entry[0].args[0].toString(), - commission: perbillToPercent(entry[1].commission.toBn()), - })) - ) - ), - [api?.isConnected] - ) - - const allValidatorsWithCtrlAcc = useFirstObservableValue( - () => - allValidators && - api && - api.query.staking.bonded - .multi(allValidators.map(({ address }) => address)) - .pipe(map((entries) => entries.map((entry) => (entry.isSome ? entry.unwrap().toString() : undefined)))), - [allValidators, api?.isConnected] - ) - - const variables = { - where: { - boundAccounts_containsAny: - (allValidatorsWithCtrlAcc - ?.concat(allValidators?.map(({ address }) => address)) - .filter((element) => !element) as string[]) ?? [], - }, - } - - const { data } = useGetMembersWithDetailsQuery({ variables, skip: !!allValidatorsWithCtrlAcc }) - - const memberships = data?.memberships?.map((rawMembership) => ({ - membership: asMemberWithDetails(rawMembership), - isVerifiedValidator: rawMembership.metadata.isVerifiedValidator ?? false, - })) - - const validatorsWithMembership: ValidatorMembership[] | undefined = useMemo(() => { - return ( - allValidators && - allValidatorsWithCtrlAcc && - memberships && - allValidators.map(({ address, commission }, index) => { - const controllerAccount = allValidatorsWithCtrlAcc[index] - return { - stashAccount: address, - controllerAccount, - commission, - ...memberships.find( - ({ membership }) => - membership.boundAccounts.includes(address) || - (controllerAccount && membership.boundAccounts.includes(controllerAccount)) - ), - } - }) - ) - }, [data, allValidators, allValidatorsWithCtrlAcc]) - - return validatorsWithMembership -} diff --git a/packages/ui/src/validators/hooks/useValidators.ts b/packages/ui/src/validators/hooks/useValidators.ts new file mode 100644 index 0000000000..9d1dba1590 --- /dev/null +++ b/packages/ui/src/validators/hooks/useValidators.ts @@ -0,0 +1,15 @@ +import { useContext, useEffect } from 'react' + +import { ValidatorsContext } from '../providers/context' + +type Props = { skip?: boolean } + +export const useValidators = ({ skip = false }: Props = {}) => { + const { setShouldFetchValidators, validators } = useContext(ValidatorsContext) + + useEffect(() => { + if (!skip) setShouldFetchValidators(true) + }, [skip]) + + return validators +} diff --git a/packages/ui/src/validators/hooks/useValidatorsList.tsx b/packages/ui/src/validators/hooks/useValidatorsList.tsx index 06f7a3e6aa..8a5ae7b717 100644 --- a/packages/ui/src/validators/hooks/useValidatorsList.tsx +++ b/packages/ui/src/validators/hooks/useValidatorsList.tsx @@ -1,116 +1,57 @@ -import { BN } from '@polkadot/util' -import { useEffect, useState } from 'react' -import { of, map, switchMap, Observable, combineLatest } from 'rxjs' +import { useContext, useEffect, useMemo, useReducer, useState } from 'react' -import { encodeAddress } from '@/accounts/model/encodeAddress' -import { Api } from '@/api' -import { useApi } from '@/api/hooks/useApi' -import { ERAS_PER_YEAR } from '@/common/constants' -import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' -import { createType } from '@/common/model/createType' -import { last } from '@/common/utils' +import { ValidatorsContext } from '../providers/context' +import { ValidatorDetailsOrder } from '../types' -import { Verification, State, ValidatorWithDetails, ValidatorMembership } from '../types' - -import { useValidatorMembers } from './useValidatorMembers' +const VALIDATOR_PER_PAGE = 7 +const DESCENDING_KEYS: ValidatorDetailsOrder['key'][] = ['apr'] export const useValidatorsList = () => { - const { api } = useApi() const [search, setSearch] = useState('') - const [isVerified, setIsVerified] = useState(null) - const [isActive, setIsActive] = useState(null) - const [visibleValidators, setVisibleValidators] = useState([]) - const validators = useValidatorMembers() + const [isVerified, setIsVerified] = useState() + const [isActive, setIsActive] = useState() + const filter = useMemo(() => ({ search, isVerified, isActive }), [search, isVerified, isActive]) - const validatorRewardPointsHistory = useFirstObservableValue( - () => api?.query.staking.erasRewardPoints.entries(), - [api?.isConnected] + const [order, handleSort] = useReducer( + (state: ValidatorDetailsOrder, key: ValidatorDetailsOrder['key']) => ({ + key, + isDescending: key !== state.key ? DESCENDING_KEYS.includes(key) : !state.isDescending, + }), + { key: 'default', isDescending: false } ) - const activeValidators = useFirstObservableValue(() => api?.query.session.validators(), [api?.isConnected]) - const getValidatorInfo = (validator: ValidatorMembership, api: Api): Observable => { - if (!activeValidators || !validatorRewardPointsHistory) return of() - const { stashAccount: address, commission } = validator - const stakingInfo$ = api.query.staking - .activeEra() - .pipe(switchMap((activeEra) => api.query.staking.erasStakers(activeEra.unwrap().index, address))) - const rewardHistory$ = api.derive.staking.stakerRewards(address) - const slashingSpans$ = api.query.staking.slashingSpans(address) - return combineLatest([stakingInfo$, rewardHistory$, slashingSpans$]).pipe( - map(([stakingInfo, rewardHistory, slashingSpans]) => { - const apr = - rewardHistory.length && !stakingInfo.total.toBn().isZero() - ? last(rewardHistory) - .eraReward.toBn() - .muln(ERAS_PER_YEAR) - .muln(commission) - .div(stakingInfo.total.toBn()) - .toNumber() - : 0 - const rewardPointsHistory = validatorRewardPointsHistory.map((entry) => ({ - era: entry[0].args[0].toNumber(), - rewardPoints: entry[1].individual.get(createType('AccountId', address))?.toNumber() ?? 0, - })) - return { - ...validator, - isActive: activeValidators.includes(address), - totalRewards: rewardHistory.reduce((total: BN, data) => total.add(data.eraReward), new BN(0)), - rewardPointsHistory, - APR: apr, - slashed: - slashingSpans.unwrap().prior.length + (slashingSpans.unwrap().lastNonzeroSlash.toNumber() > 0 ? 1 : 0), - staking: { - total: stakingInfo.total.toBn(), - own: stakingInfo.own.toBn(), - others: stakingInfo.others.map((nominator) => ({ - address: nominator.who.toString(), - staking: nominator.value.toBn(), - })), - }, - } - }) - ) - } + const { + validators, + setShouldFetchValidators, + setValidatorDetailsOptions, + validatorsWithDetails, + size = 0, + validatorsQueries, + } = useContext(ValidatorsContext) - const getValidatorsInfo = (api: Api, validators: ValidatorMembership[]) => { - const validatorInfoObservables = validators.map((validator) => getValidatorInfo(validator, api)) - return combineLatest(validatorInfoObservables) - } - - const allValidatorsWithDetails = useFirstObservableValue( - () => - api && validators && validatorRewardPointsHistory && activeValidators - ? getValidatorsInfo(api, validators) - : of([]), - [api?.isConnected, validators, validatorRewardPointsHistory, activeValidators] + const [page, setPage] = useState(1) + const pagination = useMemo( + () => ({ + page, + handlePageChange: setPage, + pageCount: Math.ceil(size / VALIDATOR_PER_PAGE), + }), + [page, size] ) useEffect(() => { - if (allValidatorsWithDetails) { - setVisibleValidators( - allValidatorsWithDetails - .filter((validator) => { - if (isActive === 'active') return validator.isActive - else if (isActive === 'waiting') return !validator.isActive - else return true - }) - .filter((validator) => { - if (isVerified === 'verified') return validator.isVerifiedValidator - else if (isVerified === 'unverified') return !validator.isVerifiedValidator - else return true - }) - .filter((validator) => { - return ( - encodeAddress(validator.stashAccount).includes(search) || validator.membership?.handle.includes(search) - ) - }) - ) - } - }, [allValidatorsWithDetails, search, isVerified, isActive]) + setShouldFetchValidators(true) + setValidatorDetailsOptions({ + filter, + order, + start: (page - 1) * VALIDATOR_PER_PAGE, + end: page * VALIDATOR_PER_PAGE, + }) + }, [filter, order, page]) - return { - visibleValidators, - length: visibleValidators.length, + const format = { + pagination, + order: { ...order, sortBy: (key: ValidatorDetailsOrder['key']) => () => handleSort(key) }, filter: { search, setSearch, @@ -120,4 +61,7 @@ export const useValidatorsList = () => { setIsActive, }, } + const allValidatorsCount = validators?.length + + return { validatorsWithDetails, validatorsQueries, allValidatorsCount, format } } diff --git a/packages/ui/src/validators/modals/NominatingRedirectModal/NominatingRedirectModal.tsx b/packages/ui/src/validators/modals/NominatingRedirectModal/NominatingRedirectModal.tsx index dd058515e7..e5c13617bc 100644 --- a/packages/ui/src/validators/modals/NominatingRedirectModal/NominatingRedirectModal.tsx +++ b/packages/ui/src/validators/modals/NominatingRedirectModal/NominatingRedirectModal.tsx @@ -29,7 +29,7 @@ export const NominatingRedirectModal = () => { size="medium" href="https://polkadot.js.org/apps/?rpc=wss://rpc.joystream.org:9944#/staking/actions" > - Norminate + Nominate diff --git a/packages/ui/src/validators/modals/ValidatorsInfo.tsx b/packages/ui/src/validators/modals/ValidatorsInfo.tsx new file mode 100644 index 0000000000..434d30b4e6 --- /dev/null +++ b/packages/ui/src/validators/modals/ValidatorsInfo.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { Link } from '@/common/components/Link' +import { RowGapBlock } from '@/common/components/page/PageContent' +import { useLocalStorage } from '@/common/hooks/useLocalStorage' +import { useToggle } from '@/common/hooks/useToggle' + +import { ButtonPrimary } from '../../common/components/buttons' +import { Checkbox } from '../../common/components/forms' +import { ArrowRightIcon } from '../../common/components/icons' +import { Modal, ModalBody, ModalFooter, ModalHeader } from '../../common/components/Modal' +import { TextMedium } from '../../common/components/typography' + +export const ValidatorsInfo = () => { + const title = 'Nominating validators on Joystream' + const buttonName = 'Start nominating' + const [check, setCheck] = useToggle(false) + const [notShowAgain, setNotShowAgain] = useLocalStorage('ValidatorsPageCheck') + const [showModal, setShowModal] = useState(true) + const closeModal = () => { + setShowModal(false) + } + const checkModal = () => { + setNotShowAgain(check) + closeModal() + } + + if (!notShowAgain && showModal) + return ( + + + + + + + The Joystream blockchain is a PoS system relying on validators. Nominating validators allows you to + participate in the Joystream governance system and earn rewards. + + + When nominating, you are at risk of having parts of your staked funds lost if the validator malfunctions + or does a poor job, resulting in a reduced return on investment. To manage your risk, we advice you to + nominate several validators (up to 16). This allows you to spread out your risk and increase your + chances of earning rewards. You can choose how much to stake with each validator, and you can change + your staking percentages at any time. + + + To begin, review each validator's performance metrics by clicking on their name in the list. When you're + ready to nominate, add the validators you'd like to nominate by clicking the "Nominate" button on the + list or directly on the validator’s profile. Once you've selected a validator, click the "Proceed" + button to initiate the nomination process. + + + + You can learn more about the Pioneer nomination{' '} + system here. + + + + + + Do not show this again. + + + {buttonName} + + + + ) + return null +} + +const InfoModalFooter = styled(ModalFooter)` + justify-items: start; + justify-content: space-between; +` diff --git a/packages/ui/src/validators/modals/validatorCard/Nominators.tsx b/packages/ui/src/validators/modals/validatorCard/Nominators.tsx index a0b2eba801..acaa3689fe 100644 --- a/packages/ui/src/validators/modals/validatorCard/Nominators.tsx +++ b/packages/ui/src/validators/modals/validatorCard/Nominators.tsx @@ -19,7 +19,7 @@ export const Nominators = ({ validator }: Props) => {
- {`Nominators (${validator.staking.others.length})`} + {`Nominators (${validator.staking?.nominators.length})`} @@ -27,12 +27,12 @@ export const Nominators = ({ validator }: Props) => { Total staked - {validator.staking.others?.map(({ address, staking }, index) => ( + {validator.staking?.nominators?.map(({ address, staking }, index) => ( - + - + ))} @@ -51,10 +51,9 @@ const Title = styled.h4` const NominatorList = styled(List)` gap: 8px; ` -const ValidatorItemWrap = styled.div` +const NominatorItemWrap = styled.div` display: grid; grid-template-columns: 1fr 1fr; - grid-template-rows: 1fr; justify-content: space-between; justify-items: end; align-items: center; @@ -65,6 +64,11 @@ const ValidatorItemWrap = styled.div` cursor: pointer; transition: ${Transitions.all}; ${TableListItemAsLinkHover} + + @media (max-width: 424px) { + grid-gap: 8px; + grid-template-columns: 1fr; + } ` const ListHeaders = styled.div` display: grid; diff --git a/packages/ui/src/validators/modals/validatorCard/ValidatorCard.tsx b/packages/ui/src/validators/modals/validatorCard/ValidatorCard.tsx index 3518f081e5..209e91d4d7 100644 --- a/packages/ui/src/validators/modals/validatorCard/ValidatorCard.tsx +++ b/packages/ui/src/validators/modals/validatorCard/ValidatorCard.tsx @@ -23,13 +23,14 @@ import { ValidatorDetail } from './ValidatorDetail' export type ValidatorCardTabs = 'Details' | 'Nominators' interface Props { + eraIndex: number | undefined cardNumber: number validator: ValidatorWithDetails selectCard: (cardNumber: number | null) => void totalCards: number } -export const ValidatorCard = React.memo(({ cardNumber, validator, selectCard, totalCards }: Props) => { +export const ValidatorCard = React.memo(({ cardNumber, validator, eraIndex, selectCard, totalCards }: Props) => { const hideModal = () => { selectCard(null) } @@ -82,7 +83,7 @@ export const ValidatorCard = React.memo(({ cardNumber, validator, selectCard, to - {activeTab === 'Details' && } + {activeTab === 'Details' && } {activeTab === 'Nominators' && } diff --git a/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx b/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx index 559acb693b..e46b788ce1 100644 --- a/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx +++ b/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx @@ -8,9 +8,10 @@ import { RowGapBlock } from '@/common/components/page/PageContent' import { SidePaneBody, SidePaneLabel, SidePaneRow, SidePaneText } from '@/common/components/SidePane' import { NumericValueStat, StatisticsThreeColumns, TokenValueStat } from '@/common/components/statistics' import { TextSmall } from '@/common/components/typography' -import { BN_ZERO, ERA_DEPTH } from '@/common/constants' +import { BN_ZERO } from '@/common/constants' import { plural } from '@/common/helpers' import { useModal } from '@/common/hooks/useModal' +import { whenDefined } from '@/common/utils' import RewardPointsChart from '@/validators/components/RewardPointChart' import { ValidatorWithDetails } from '../../types' @@ -18,12 +19,21 @@ import { NominatingRedirectModalCall } from '../NominatingRedirectModal' interface Props { validator: ValidatorWithDetails + eraIndex: number | undefined hideModal: () => void } -export const ValidatorDetail = ({ validator, hideModal }: Props) => { +export const ValidatorDetail = ({ validator, eraIndex, hideModal }: Props) => { const { showModal } = useModal() + const uptime = whenDefined(validator.rewardPointsHistory, (rewardPointsHistory) => { + const firstEra = rewardPointsHistory.at(0)?.era + if (!eraIndex || !firstEra) return + const totalEras = eraIndex - firstEra + const validatedEra = rewardPointsHistory.filter(({ rewardPoints }) => rewardPoints > 0).length + return `${((validatedEra / totalEras) * 100).toFixed(1)}%` + }) + return ( <> @@ -34,35 +44,36 @@ export const ValidatorDetail = ({ validator, hideModal }: Props) => { Total reward - + `${apr}%`)}> Average APR - a.add(b.staking), BN_ZERO)}> + a.add(b.staking), BN_ZERO)} + > Staked by nominators Status - + `${slashed} time${plural(slashed)}`)}> Slashed - rewardPoints).length / ERA_DEPTH) * - 100 - ).toFixed(3)}%`} - > + Uptime - -
Era points
- - - -
+ {validator.rewardPointsHistory && ( + +
Era points
+ +
+ +
+
+
+ )}
About
@@ -106,6 +117,14 @@ const Details = styled(RowGapBlock)` const ModalStatistics = styled(StatisticsThreeColumns)` grid-gap: 10px; + + @media (max-width: 767px) { + grid-template-columns: 1fr 1fr; + } + + @media (max-width: 424px) { + grid-template-columns: 1fr; + } ` const Stat = styled(NumericValueStat)` @@ -114,5 +133,10 @@ const Stat = styled(NumericValueStat)` const RewardPointsChartWrapper = styled.div` width: 100%; - height: 200px; + overflow: auto; + + > div { + min-width: 500px; + height: 200px; + } ` diff --git a/packages/ui/src/validators/providers/context.tsx b/packages/ui/src/validators/providers/context.tsx new file mode 100644 index 0000000000..a7073878a8 --- /dev/null +++ b/packages/ui/src/validators/providers/context.tsx @@ -0,0 +1,8 @@ +import { createContext } from 'react' + +import { UseValidators } from './provider' + +export const ValidatorsContext = createContext({ + setShouldFetchValidators: () => {}, + setValidatorDetailsOptions: () => {}, +}) diff --git a/packages/ui/src/validators/providers/provider.tsx b/packages/ui/src/validators/providers/provider.tsx new file mode 100644 index 0000000000..63035a17b1 --- /dev/null +++ b/packages/ui/src/validators/providers/provider.tsx @@ -0,0 +1,76 @@ +import React, { ReactNode, useState } from 'react' +import { map } from 'rxjs' + +import { useApi } from '@/api/hooks/useApi' +import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' +import { perbillToPercent } from '@/common/utils' + +import { Validator, ValidatorWithDetails } from '../types' + +import { ValidatorsContext } from './context' +import { CommonValidatorsQueries, useValidatorsQueries } from './useValidatorsQueries' +import { ValidatorDetailsOptions, useValidatorsWithDetails } from './useValidatorsWithDetails' + +interface Props { + children: ReactNode +} + +export interface UseValidators { + setShouldFetchValidators: (shouldFetchValidators: boolean) => void + setValidatorDetailsOptions: (options: ValidatorDetailsOptions) => void + validators?: Validator[] + validatorsWithDetails?: ValidatorWithDetails[] + size?: number + validatorsQueries?: CommonValidatorsQueries +} + +export const ValidatorContextProvider = (props: Props) => { + const { api } = useApi() + + const [shouldFetchValidators, setShouldFetchValidators] = useState(false) + + const allValidators = useFirstObservableValue(() => { + if (!shouldFetchValidators) return + + return api?.query.staking.validators.entries().pipe( + map((entries) => + entries.map((entry) => ({ + stashAccount: entry[0].args[0].toString(), + commission: perbillToPercent(entry[1].commission.toBn()), + })) + ) + ) + }, [api?.isConnected, shouldFetchValidators]) + + const allValidatorsWithCtrlAcc = useFirstObservableValue(() => { + if (!allValidators) return + + return api?.query.staking.bonded.multi(allValidators.map((validator) => validator.stashAccount)).pipe( + map((entries) => + entries.map((entry, index) => { + const validator = allValidators[index] + const controllerAccount = entry.isSome ? entry.unwrap().toString() : undefined + return { ...validator, controllerAccount } + }) + ) + ) + }, [allValidators, api?.isConnected]) + + const validatorsQueries = useValidatorsQueries() + + const { validatorsWithDetails, size, setValidatorDetailsOptions } = useValidatorsWithDetails( + allValidatorsWithCtrlAcc, + validatorsQueries + ) + + const value = { + setShouldFetchValidators, + setValidatorDetailsOptions, + validators: allValidatorsWithCtrlAcc, + validatorsWithDetails, + size, + validatorsQueries, + } + + return {props.children} +} diff --git a/packages/ui/src/validators/providers/useValidatorsQueries.ts b/packages/ui/src/validators/providers/useValidatorsQueries.ts new file mode 100644 index 0000000000..16f8055d59 --- /dev/null +++ b/packages/ui/src/validators/providers/useValidatorsQueries.ts @@ -0,0 +1,91 @@ +import { Vec } from '@polkadot/types' +import { AccountId } from '@polkadot/types/interfaces' +import { PalletStakingExposure } from '@polkadot/types/lookup' +import BN from 'bn.js' +import { useMemo } from 'react' +import { Observable, combineLatest, map, switchMap } from 'rxjs' + +import { useApi } from '@/api/hooks/useApi' + +import { keepFirst } from './utils' + +type ActiveEra = { index: number; startedOn: number } + +type ActiveValidators = Vec + +type Stakers = Map> + +type EraRewards = { + era: number + totalPoints: number + individual: Record + totalReward: BN +} + +export type CommonValidatorsQueries = { + activeEra$: Observable + activeValidators$: Observable + stakers$: Observable + validatorsRewards$: Observable +} + +export const useValidatorsQueries = (): CommonValidatorsQueries | undefined => { + const { api } = useApi() + + return useMemo(() => { + if (!api) return + + const activeValidators$ = api.query.session.validators().pipe(keepFirst()) + + const activeEra$ = api.query.staking.activeEra().pipe( + map((activeEra) => ({ + index: activeEra.unwrap().index.toNumber(), + startedOn: activeEra.unwrap().start.unwrap().toNumber(), + })), + keepFirst() + ) + + const stakers$ = activeValidators$.pipe( + map( + (activeValidators) => + new Map( + activeValidators.map((account) => { + const staker$ = activeEra$.pipe(switchMap(({ index }) => api.query.staking.erasStakers(index, account))) + return [account.toString(), staker$] + }) + ) + ), + keepFirst() + ) + + const erasRewards$ = api.derive.staking.erasRewards() + const eraRewardPoints$ = api.derive.staking.erasPoints() + + const validatorsRewards$ = combineLatest([erasRewards$, eraRewardPoints$]).pipe( + map(([erasRewards, eraRewardPoints]) => + eraRewardPoints.map((points, index) => { + const era = points.era.toNumber() + const reward = erasRewards[index] + + if (era !== reward?.era.toNumber()) { + throw Error( + `derive.staking.erasRewards and derive.staking.erasPoints eras didn't match. Era #${era} is missing` + ) + } + + return { + era, + totalPoints: points.eraPoints.toNumber(), + totalReward: reward.eraReward, + individual: Object.fromEntries( + Object.entries(points.validators).map(([address, points]) => [address, points.toNumber()]) + ), + } + }) + ), + keepFirst() + ) + + return { activeEra$, activeValidators$, stakers$, validatorsRewards$ } + }, [api?.isConnected]) +} diff --git a/packages/ui/src/validators/providers/useValidatorsWithDetails.ts b/packages/ui/src/validators/providers/useValidatorsWithDetails.ts new file mode 100644 index 0000000000..64da55ab15 --- /dev/null +++ b/packages/ui/src/validators/providers/useValidatorsWithDetails.ts @@ -0,0 +1,127 @@ +import { useMemo, useState } from 'react' +import { map, merge, Observable, of, scan, switchMap, throttleTime } from 'rxjs' + +import { useApi } from '@/api/hooks/useApi' +import { useObservable } from '@/common/hooks/useObservable' +import { filterObservableList, mapObservableList, sortObservableList } from '@/common/model/ObservableList' +import { useGetMembersWithDetailsQuery } from '@/memberships/queries' +import { asMemberWithDetails } from '@/memberships/types' + +import { Validator, ValidatorDetailsFilter, ValidatorDetailsOrder, ValidatorInfo, ValidatorWithDetails } from '../types' + +import { CommonValidatorsQueries } from './useValidatorsQueries' +import { getValidatorSortingFns, getValidatorsFilters, getValidatorInfo } from './utils' + +export type ValidatorDetailsOptions = { + filter: ValidatorDetailsFilter + order: ValidatorDetailsOrder + start: number + end: number +} + +export const useValidatorsWithDetails = ( + allValidatorsWithCtrlAcc: Validator[] | undefined, + validatorsQueries: CommonValidatorsQueries | undefined +) => { + const { api } = useApi() + + const [validatorDetailsOptions, setValidatorDetailsOptions] = useState() + + const variables = useMemo(() => { + if (!allValidatorsWithCtrlAcc || !validatorDetailsOptions) return + + const addresses = allValidatorsWithCtrlAcc.flatMap(({ stashAccount: stash, controllerAccount: ctrl }) => + ctrl ? [stash, ctrl] : [stash] + ) + const accountsFilter = [ + { rootAccount_in: addresses }, + { controllerAccount_in: addresses }, + { boundAccounts_containsAny: addresses }, + ] + + return { where: { OR: accountsFilter } } + }, [allValidatorsWithCtrlAcc, !validatorDetailsOptions]) + + const { data } = useGetMembersWithDetailsQuery({ variables, skip: !variables }) + + const memberships = data?.memberships?.map((rawMembership) => ({ + membership: asMemberWithDetails(rawMembership), + isVerifiedValidator: rawMembership.metadata.isVerifiedValidator ?? false, + })) + + const validatorsWithMembership: ValidatorWithDetails[] | undefined = useMemo(() => { + if (!memberships || !allValidatorsWithCtrlAcc || !validatorDetailsOptions) return + + return allValidatorsWithCtrlAcc.map((validator) => { + const { stashAccount, controllerAccount } = validator + const boundMemberships = memberships + .filter( + ({ membership }) => + (controllerAccount && membership.boundAccounts.includes(controllerAccount)) || + membership.boundAccounts.includes(stashAccount) || + membership.controllerAccount === controllerAccount || + membership.controllerAccount === stashAccount || + membership.rootAccount === controllerAccount || + membership.rootAccount === stashAccount + ) + .sort((a, b) => + a.isVerifiedValidator === b.isVerifiedValidator + ? Number(a.membership.id) - Number(b.membership.id) + : a.isVerifiedValidator + ? -1 + : 1 + ) + + return { ...validator, ...boundMemberships[0] } + }) + }, [data, allValidatorsWithCtrlAcc, !validatorDetailsOptions]) + + const validatorsInfo$ = useMemo(() => { + if (!api || !validatorsWithMembership || !validatorsQueries) return + + const validatorsInfo = validatorsWithMembership.map((validator) => + getValidatorInfo(validator, validatorsQueries, api) + ) + + return of(validatorsInfo) + }, [api?.isConnected, validatorsWithMembership, validatorsQueries]) + + const [filteredValidatorsInfo$, size$] = useMemo<[Observable, Observable] | []>(() => { + if (!validatorsInfo$ || !validatorDetailsOptions) return [] + + const filtered$ = getValidatorsFilters(validatorDetailsOptions.filter).reduce( + (validators$, predicate) => (predicate ? validators$.pipe(filterObservableList(predicate)) : validators$), + validatorsInfo$ + ) + + const size$ = filtered$.pipe(map((filtered) => filtered.length)) + + return [filtered$, size$] + }, [validatorsInfo$, validatorDetailsOptions?.filter]) + + const validatorsWithDetails = useObservable(() => { + if (!filteredValidatorsInfo$ || !size$ || !validatorDetailsOptions) return + + const { order, start, end } = validatorDetailsOptions + const sortDirection = order.isDescending ? -1 : 1 + const [sortMapFn, sortCompareFn] = getValidatorSortingFns(order.key) + + return filteredValidatorsInfo$.pipe( + sortObservableList(sortMapFn, (a, b) => sortCompareFn(a, b) * sortDirection), + map((validators) => validators.slice(start, end)), + mapObservableList(({ validator, ...rest }) => + merge(of(validator), ...Object.values(rest)).pipe( + scan((validator: ValidatorWithDetails, part) => ({ ...validator, ...part }), validator) + ) + ), + throttleTime(10, undefined, { leading: false, trailing: true }), + switchMap((validators) => size$.pipe(map((size) => (!validators[0] && size > 0 ? undefined : validators)))) + ) + }, [filteredValidatorsInfo$, size$, validatorDetailsOptions?.start, validatorDetailsOptions?.order]) + + return { + validatorsWithDetails, + size: useObservable(() => size$, [size$]), + setValidatorDetailsOptions, + } +} diff --git a/packages/ui/src/validators/providers/utils.ts b/packages/ui/src/validators/providers/utils.ts new file mode 100644 index 0000000000..00c4349b5f --- /dev/null +++ b/packages/ui/src/validators/providers/utils.ts @@ -0,0 +1,166 @@ +import { Vec } from '@polkadot/types' +import { AccountId } from '@polkadot/types/interfaces' +import { map, Observable, of, OperatorFunction, ReplaySubject, share, switchMap } from 'rxjs' + +import { Api } from '@/api' +import { BN_ZERO, ERAS_PER_YEAR } from '@/common/constants' +import { isDefined } from '@/common/utils' + +import { ValidatorDetailsFilter, ValidatorDetailsOrder, ValidatorInfo, ValidatorWithDetails } from '../types' + +import { CommonValidatorsQueries } from './useValidatorsQueries' + +export const getValidatorsFilters = ({ + isActive, + isVerified, + search = '', +}: ValidatorDetailsFilter): (false | ((i: ValidatorInfo) => Observable))[] => { + const s = search.toLowerCase() + const isMatch = (value: string | undefined) => value && value.toLowerCase().search(s) >= 0 + + return [ + // Status filter + isDefined(isActive) && (({ isActive$ }) => isActive$.pipe(map((validator) => validator.isActive === isActive))), + + // Verification filter + isDefined(isVerified) && (({ validator }: ValidatorInfo) => of(!!validator.isVerifiedValidator === isVerified)), + + // Search filter + s.length > 2 && + (({ validator: { membership, stashAccount, controllerAccount } }: ValidatorInfo) => + of(isMatch(membership?.handle) || isMatch(stashAccount) || isMatch(controllerAccount))), + ] +} + +export const filterValidatorsByIsActive = (validators: ValidatorWithDetails[], isActive: boolean) => + map((activeValidators: Vec) => + validators.filter(({ stashAccount }) => activeValidators.includes(stashAccount) === isActive) + ) + +export const getValidatorSortingFns = ( + key: ValidatorDetailsOrder['key'] +): [ + (item: ValidatorInfo) => Observable, + (a: ValidatorWithDetails, b: ValidatorWithDetails) => number +] => { + switch (key) { + case 'default': + return [ + (item) => of(item.validator), + (a, b) => { + if (!a.isVerifiedValidator !== !b.isVerifiedValidator) { + return a.isVerifiedValidator ? -1 : 1 + } + + const handleA = a.membership?.handle + const handleB = b.membership?.handle + if ((handleA || handleB) && handleA !== handleB) { + return !handleA ? 1 : !handleB ? -1 : handleA.localeCompare(handleB) + } + + return a.stashAccount.localeCompare(b.stashAccount) + }, + ] + + case 'commission': + return [(item) => of(item.validator), (a, b) => a.commission - b.commission] + + case 'apr': + return [ + (item) => item.apr$.pipe(map(({ APR }) => ({ ...item.validator, APR }))), + (a, b) => (a.APR ?? -1) - (b.APR ?? -1), + ] + } +} + +export const getValidatorInfo = ( + validator: ValidatorWithDetails, + { activeValidators$, stakers$, validatorsRewards$ }: CommonValidatorsQueries, + api: Api +): ValidatorInfo => { + const address = validator.stashAccount + + const isActive$ = activeValidators$.pipe( + map((activeValidators) => ({ isActive: activeValidators.includes(address) })), + keepFirst() + ) + + const rewardHistory$ = validatorsRewards$.pipe( + map((allRewards) => + allRewards.flatMap(({ era, totalPoints, individual, totalReward }) => { + if (!individual[address]) return [] + const eraPoints = Number(individual[address]) + const eraReward = totalReward.muln(eraPoints / totalPoints) + return { era, eraReward, eraPoints } + }) + ) + ) + + const reward$ = rewardHistory$.pipe( + map((rewards) => ({ + rewardPointsHistory: rewards.map(({ era, eraPoints }) => ({ era, rewardPoints: eraPoints })), + totalRewards: rewards.reduce((total, { eraReward }) => total.add(eraReward ?? BN_ZERO), BN_ZERO), + })), + keepFirst() + ) + + const staking$ = stakers$.pipe( + switchMap((stakers) => stakers.get(address.toString()) ?? of(undefined)), + map((stakingInfo) => { + if (!stakingInfo) return { staking: { total: BN_ZERO, own: BN_ZERO, nominators: [] } } + + const total = stakingInfo.total.toBn() + const nominators = stakingInfo.others.map((nominator) => ({ + address: nominator.who.toString(), + staking: nominator.value.toBn(), + })) + + return { staking: { total, own: stakingInfo.own.toBn(), nominators } } + }), + keepFirst() + ) + + const apr$ = staking$.pipe( + switchMap(({ staking }) => { + if (staking.total.isZero()) return of({}) + + return rewardHistory$.pipe( + map((rewards) => { + if (!rewards.length) return {} + + const commission = validator.commission + const averageReward = rewards.reduce((sum, reward) => sum.add(reward.eraReward), BN_ZERO).divn(rewards.length) + const apr = + Number( + averageReward + .muln(ERAS_PER_YEAR) + .muln(100 - commission) + .muln(100) + .div(staking.total) + ) / 100 + return { APR: apr } + }) + ) + }), + keepFirst() + ) + + const slashed$ = api.query.staking.slashingSpans(address).pipe( + map((slashingSpans) => { + if (!slashingSpans.isSome) return { slashed: 0 } + const { prior, lastNonzeroSlash } = slashingSpans.unwrap() + return { slashed: prior.length + (lastNonzeroSlash.gtn(0) ? 1 : 0) } + }), + keepFirst() + ) + + return { validator, isActive$, reward$, apr$, staking$, slashed$ } +} + +export const keepFirst = (): OperatorFunction => + share({ + connector: () => new ReplaySubject(1), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) diff --git a/packages/ui/src/validators/types/Validator.ts b/packages/ui/src/validators/types/Validator.ts index 448f47a6fd..2b7e006609 100644 --- a/packages/ui/src/validators/types/Validator.ts +++ b/packages/ui/src/validators/types/Validator.ts @@ -1,4 +1,5 @@ import BN from 'bn.js' +import { Observable } from 'rxjs' import { Address } from '@/common/types' import { MemberWithDetails } from '@/memberships/types' @@ -8,26 +9,44 @@ export interface RewardPoints { rewardPoints: number } -export interface ValidatorWithDetails extends ValidatorMembership { - isActive: boolean - totalRewards: BN - rewardPointsHistory: RewardPoints[] - APR: number - staking: { - total: BN - own: BN - others: { - address: Address - staking: BN - }[] - } - slashed: number -} - -export interface ValidatorMembership { +export interface Validator { stashAccount: Address controllerAccount?: Address + commission: number +} + +export interface ValidatorWithDetails extends Validator { isVerifiedValidator?: boolean membership?: MemberWithDetails - commission: number + isActive?: boolean + totalRewards?: BN + rewardPointsHistory?: RewardPoints[] + APR?: number + staking?: { total: BN; own: BN; nominators: Nominator[] } + slashed?: number +} + +interface Nominator { + address: Address + staking: BN +} + +export interface ValidatorDetailsFilter { + search?: string + isVerified?: boolean + isActive?: boolean +} + +export interface ValidatorDetailsOrder { + key: 'default' | 'commission' | 'apr' + isDescending: boolean +} + +export type ValidatorInfo = { + validator: ValidatorWithDetails + isActive$: Observable> + reward$: Observable> + apr$: Observable> + staking$: Observable> + slashed$: Observable> } diff --git a/packages/ui/src/validators/types/index.ts b/packages/ui/src/validators/types/index.ts index 8b80ea8722..7e842456bc 100644 --- a/packages/ui/src/validators/types/index.ts +++ b/packages/ui/src/validators/types/index.ts @@ -1,4 +1 @@ export * from './Validator' - -export type Verification = null | 'verified' | 'unverified' -export type State = null | 'active' | 'waiting' diff --git a/packages/ui/test/_mocks/keyring/signers.ts b/packages/ui/test/_mocks/keyring/signers.ts index 436866b117..cdeba94b38 100644 --- a/packages/ui/test/_mocks/keyring/signers.ts +++ b/packages/ui/test/_mocks/keyring/signers.ts @@ -8,7 +8,7 @@ export const aliceStash = { } export const bob = { name: 'bob', - address: 'j4UYhDYJ4pz2ihhDDzu69v2JTVeGaGmTebmBdWaX2ANVinXyE', + address: 'j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW', } export const bobStash = { name: 'bob_stash', diff --git a/packages/ui/test/membership/modals/UpdateMembershipModal.test.tsx b/packages/ui/test/membership/modals/UpdateMembershipModal.test.tsx deleted file mode 100644 index 1283674837..0000000000 --- a/packages/ui/test/membership/modals/UpdateMembershipModal.test.tsx +++ /dev/null @@ -1,210 +0,0 @@ -import { cryptoWaitReady } from '@polkadot/util-crypto' -import { act, configure, fireEvent, render, screen } from '@testing-library/react' -import BN from 'bn.js' -import { set } from 'lodash' -import React from 'react' -import { of } from 'rxjs' - -import { ApiContext } from '@/api/providers/context' -import { GlobalModals } from '@/app/GlobalModals' -import { MembershipExternalResourceType } from '@/common/api/queries' -import { ModalContextProvider } from '@/common/providers/modal/provider' -import { last } from '@/common/utils' -import { UpdateMembershipModal } from '@/memberships/modals/UpdateMembershipModal' -import { MembershipContext } from '@/memberships/providers/membership/context' -import { MyMemberships } from '@/memberships/providers/membership/provider' -import { MemberWithDetails } from '@/memberships/types' - -import { getButton } from '../../_helpers/getButton' -import { selectFromDropdown } from '../../_helpers/selectFromDropdown' -import { createBalanceOf } from '../../_mocks/chainTypes' -import { alice, aliceStash, bob, bobStash } from '../../_mocks/keyring' -import { getMember } from '../../_mocks/members' -import { MockKeyringProvider, MockQueryNodeProviders } from '../../_mocks/providers' -import { setupMockServer } from '../../_mocks/server' -import { - stubAccounts, - stubApi, - stubBatchTransactionFailure, - stubBatchTransactionSuccess, - stubDefaultBalances, - stubTransaction, -} from '../../_mocks/transactions' -import { mockUseModalCall } from '../../setup' - -configure({ testIdAttribute: 'id' }) - -describe('UI: UpdatedMembershipModal', () => { - beforeAll(async () => { - await cryptoWaitReady() - jest.spyOn(console, 'log').mockImplementation() - stubAccounts([alice, aliceStash, bob, bobStash]) - }) - - mockUseModalCall({ - modalData: { - member: { - ...getMember('alice'), - externalResources: [{ source: MembershipExternalResourceType.Twitter, value: 'empty' }], - } as MemberWithDetails, - }, - }) - - afterAll(() => { - jest.restoreAllMocks() - }) - - setupMockServer() - - const api = stubApi() - let batchTx: any - let profileTxMock: jest.Mock - - const useMyMemberships: MyMemberships = { - active: undefined, - members: [], - setActive: (member) => (useMyMemberships.active = member), - isLoading: false, - hasMembers: true, - helpers: { - getMemberIdByBoundAccountAddress: () => undefined, - }, - } - - beforeEach(() => { - stubDefaultBalances() - set(api, 'api.query.members.membershipPrice', () => of(createBalanceOf(100))) - set(api, 'api.query.members.memberIdByHandleHash.size', () => of(new BN(0))) - stubTransaction(api, 'api.tx.members.updateProfile') - stubTransaction(api, 'api.tx.members.updateAccounts') - batchTx = stubTransaction(api, 'api.tx.utility.batch') - stubTransaction(api, 'api.tx.members.updateProfile') - profileTxMock = api.api.tx.members.updateProfile as unknown as jest.Mock - useMyMemberships.members = [getMember('alice'), getMember('bob')] - useMyMemberships.setActive(getMember('bob')) - }) - - it('Renders a modal', async () => { - act(() => { - renderModal() - }) - expect(await screen.findByText('Edit membership')).toBeDefined() - }) - - it('Is initially disabled', async () => { - act(() => { - renderModal() - }) - expect(await getButton(/^Save changes$/i)).toBeDisabled() - }) - - it('Enables button on external resources change', async () => { - act(() => { - renderModal() - }) - expect(await getButton(/^Save changes$/i)).toBeDisabled() - fireEvent.change(screen.getByTestId('twitter-input'), { target: { value: 'joystream@mail.com' } }) - - expect(await getButton(/^Save changes$/i)).toBeEnabled() - }) - - it('Enables button on member field change', async () => { - act(() => { - renderModal() - }) - expect(await getButton(/^Save changes$/i)).toBeDisabled() - fireEvent.change(screen.getByLabelText(/member name/i), { target: { value: 'Bobby Bob' } }) - - expect(await getButton(/^Save changes$/i)).toBeEnabled() - }) - - it('Enables save button on account change', async () => { - act(() => { - renderModal() - }) - await selectFromDropdown('root account', 'bob') - expect(await getButton(/^Save changes$/i)).toBeEnabled() - }) - - it('Disables button when invalid avatar URL', async () => { - act(() => { - renderModal() - }) - - fireEvent.change(await screen.findByLabelText(/member avatar/i), { target: { value: 'avatar' } }) - expect(await getButton(/^Save changes$/i)).toBeDisabled() - - fireEvent.change(await screen.findByLabelText(/member avatar/i), { - target: { value: 'http://example.com/example.jpg' }, - }) - expect(await getButton(/^Save changes$/i)).toBeEnabled() - }) - - describe('Authorize - member field', () => { - const newMemberName = 'Bobby Bob' - const newMemberEmail = 'joystream@mail.com' - async function changeNameAndSave() { - await act(async () => { - fireEvent.change(screen.getByLabelText(/member name/i), { target: { value: newMemberName } }) - fireEvent.change(screen.getByTestId('twitter-input'), { target: { value: newMemberEmail } }) - - fireEvent.click(await screen.findByText(/^Save changes$/i)) - }) - } - - it('Authorize step', async () => { - act(() => { - renderModal() - }) - - await changeNameAndSave() - const txCall = profileTxMock.mock.calls[0] - const memberMetadata = Buffer.from(last(txCall) as Uint8Array).toString('utf8') - - expect(memberMetadata.includes(newMemberName)).toBe(true) - expect(memberMetadata.includes(newMemberEmail)).toBe(true) - expect(await screen.findByText('modals.authorizeTransaction.title')).toBeDefined() - expect((await screen.findByText(/^modals.transactionFee.label/i))?.nextSibling?.textContent).toBe('25') - }) - - it('Success step', async () => { - stubBatchTransactionSuccess(batchTx) - await act(async () => { - renderModal() - await changeNameAndSave() - }) - fireEvent.click(screen.getByText(/^sign and update a member$/i)) - expect(await screen.findByText('Success')).toBeDefined() - }) - - it('Failure step', async () => { - act(() => { - renderModal() - }) - - stubBatchTransactionFailure(batchTx) - await changeNameAndSave() - - fireEvent.click(screen.getByText(/^sign and update a member$/i)) - - expect(await screen.findByText('Failure')).toBeDefined() - }) - }) - - function renderModal() { - render( - - - - - - - - - - - - - ) - } -}) diff --git a/packages/ui/test/membership/modals/UpdateMembershipModalUtils.test.tsx b/packages/ui/test/membership/modals/UpdateMembershipModalUtils.test.tsx deleted file mode 100644 index 723050e12e..0000000000 --- a/packages/ui/test/membership/modals/UpdateMembershipModalUtils.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { - changedOrNull, - getChangedFields, - hasAnyEdits, -} from '../../../src/memberships/modals/UpdateMembershipModal/utils' - -describe('UI: UpdatedMembershipModal - helpers', () => { - const member = { - id: '0', - name: 'Alice Member', - handle: 'alice_handle', - about: '', - avatarUri: '', - rootAccount: 'j4W7rVcUCxi2crhhjRq46fNDRbVHTjJrz6bKxZwehEMQxZeSf', - controllerAccount: 'j4VdDQVdwFYfQ2MvEdLT2EYZx4ALPQQ6yMyZopKoZEQmXcJrT', - isFoundingMember: true, - isVerified: true, - inviteCount: 5, - } - const form = { - id: '0', - name: 'Alice Member', - handle: 'alice_handle', - about: '', - avatarUri: '', - rootAccount: { - address: 'j4W7rVcUCxi2crhhjRq46fNDRbVHTjJrz6bKxZwehEMQxZeSf', - name: '', - }, - controllerAccount: { - address: 'j4VdDQVdwFYfQ2MvEdLT2EYZx4ALPQQ6yMyZopKoZEQmXcJrT', - name: '', - }, - } - const changedAccount = { - ...form, - ...{ - rootAccount: { name: '', address: 'foo-bar' }, - }, - } - const changedName = { - ...form, - ...{ - name: 'Foo Bar', - }, - } - const changedMultiple = { - ...form, - ...{ - controllerAccount: { name: '', account: 'foo-bar' }, - handle: 'bax', - name: 'Foo Bar', - }, - } - - describe('getChangedFields', () => { - it('nothing changed', () => { - expect(getChangedFields(form, member)).toEqual([]) - }) - - it('account changed', () => { - expect(getChangedFields(changedAccount, member)).toEqual(['rootAccount']) - }) - - it('name changed', () => { - expect(getChangedFields(changedName, member)).toEqual(['name']) - }) - - it('multiple fields changed', () => { - expect(getChangedFields(changedMultiple, member)).toEqual(['name', 'handle', 'controllerAccount']) - }) - }) - - describe('hasAnyEdits', () => { - it('nothing changed', () => { - expect(hasAnyEdits(form, member)).toBeFalsy() - }) - - it('account changed', () => { - expect(hasAnyEdits(changedAccount, member)).toBeTruthy() - }) - - it('name changed', () => { - expect(hasAnyEdits(changedName, member)).toBeTruthy() - }) - - it('multiple fields changed', () => { - expect(hasAnyEdits(changedMultiple, member)).toBeTruthy() - }) - }) - - describe('changedOrNull', () => { - it('name changed', () => { - expect(changedOrNull(changedName, member)).toEqual({ - name: 'Foo Bar', - id: null, - handle: null, - about: null, - avatarUri: null, - rootAccount: null, - controllerAccount: null, - }) - }) - - it('account changed', () => { - expect(changedOrNull(changedAccount, member)).toEqual({ - id: null, - handle: null, - name: null, - about: null, - avatarUri: null, - rootAccount: { name: '', address: 'foo-bar' }, - controllerAccount: null, - }) - }) - - it('multiple changed', () => { - expect(changedOrNull(changedMultiple, member)).toEqual({ - id: null, - handle: 'bax', - name: 'Foo Bar', - about: null, - avatarUri: null, - rootAccount: null, - controllerAccount: { name: '', account: 'foo-bar' }, - }) - }) - }) -})