From 23e07ff2f0d7a391d3c6edabb9b4c66b07458fa3 Mon Sep 17 00:00:00 2001 From: eshark9312 <129978066+eshark9312@users.noreply.github.com> Date: Thu, 28 Dec 2023 09:19:46 -0800 Subject: [PATCH 01/14] Validator profile creation (#4428) * Compoenents Created * RowInline created * create profile modal complete * profile modification modal complete * change BuyMembershipModal to multiTransaction modal * add Bonding Validator Account Transaction,fix the machine * fix the bug in machine * fix the bug * complete the UI,but did not implement transaction for binding validator account * lint:fix * add bondValidatorAcc tx * update storybook * fix mistake * lint fix * fix storybook * add storybook for update validator membership * Revert "fix storybook" This reverts commit 489d7f6b662c53de4cd043f29a906bf20738d129. * add some happy and failure case * Metadata to BYTES for UpdateProfile Tx * buyValidatorMembership flow draft * update machine, modal flow * fix machine * address merge conflicts * lint --fix * update MembershipForm, UpdateMembershipForm * update interaction test * fix machine self transition condition, correct PlusIcon import * fix signer, update test * revert changes on UpdateMembership * fix * update app.stories.tsx with validator provider mocking * Revert "update app.stories.tsx with validator provider mocking" This reverts commit d3182d216120ca7125c9bf313eea8a5b5f7c6af5. * update storybook comment * update buymembership machine * update buyMembership machine * fix * Fix the duplicated transaction signing * Improve binding tests * Change the select validator account function * add validatorProvider * check validator account * Count the membership's root/controller account into the validator membership * fix * Skip validator query until it's needed --------- Co-authored-by: Theophile Sandoz --- packages/ui/.storybook/preview.tsx | 13 +- packages/ui/src/app/App.stories.tsx | 545 +++++++++++++++++- packages/ui/src/app/Providers.tsx | 35 +- .../ui/src/common/components/Modal/Modals.tsx | 10 + .../ui/src/common/components/forms/Label.tsx | 3 +- .../ui/src/common/components/icons/index.ts | 1 + .../AddStakingAccCandidateModal.tsx | 34 ++ .../BuyMembershipFormModal.tsx | 155 ++++- .../BuyMembershipModal/BuyMembershipModal.tsx | 54 +- .../BuyMembershipSignModal.tsx | 29 +- .../ConfirmStakingAccModal.tsx | 34 ++ .../modals/BuyMembershipModal/machine.ts | 75 ++- .../modals/UpdateMembershipModal/types.ts | 4 + .../validators/hooks/useValidatorMembers.tsx | 75 --- .../ui/src/validators/hooks/useValidators.ts | 16 + .../validators/hooks/useValidatorsList.tsx | 4 +- .../ui/src/validators/providers/context.tsx | 5 + .../ui/src/validators/providers/provider.tsx | 121 ++++ 18 files changed, 1091 insertions(+), 122 deletions(-) create mode 100644 packages/ui/src/memberships/modals/BuyMembershipModal/AddStakingAccCandidateModal.tsx create mode 100644 packages/ui/src/memberships/modals/BuyMembershipModal/ConfirmStakingAccModal.tsx delete mode 100644 packages/ui/src/validators/hooks/useValidatorMembers.tsx create mode 100644 packages/ui/src/validators/hooks/useValidators.ts create mode 100644 packages/ui/src/validators/providers/context.tsx create mode 100644 packages/ui/src/validators/providers/provider.tsx 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/app/App.stories.tsx b/packages/ui/src/app/App.stories.tsx index b09248ba0a..3aaa81a2d7 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,7 +10,12 @@ 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, @@ -40,6 +46,8 @@ type Args = { onTransfer: jest.Mock onSubscribeEmail: jest.Mock onConfirmEmail: jest.Mock + onAddStakingAccount: jest.Mock + onConfirmStakingAccount: jest.Mock } type Story = StoryObj> @@ -47,6 +55,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 +82,8 @@ export default { onTransfer: { action: 'BalanceTransfer' }, onSubscribeEmail: { action: 'SubscribeEmail' }, onConfirmEmail: { action: 'ConfirmEmail' }, + onAddStakingAccount: { action: 'AddStakingAccount' }, + onConfirmStakingAccount: { action: 'ConfirmStakingAccount' }, }, args: { @@ -99,7 +110,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 +124,71 @@ export default { members: { membershipPrice: joy(20) }, council: { stage: { stage: { isIdle: true }, changedAt: 123 } }, referendum: { stage: {} }, + staking: { + bonded: { + multi: [ + 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', + 'j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW', + 'j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP', + 'j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz', + 'j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa', + 'j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN', + 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', + 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', + 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', + 'j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt', + 'j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM', + ], + }, + validators: { + entries: [ + [ + { args: ['5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y'] }, + { commission: 0.05 * 10 ** 9, blocked: false }, + ], + [ + { args: ['5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy'] }, + { 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 }, + ], + ], + }, + }, }, tx: { @@ -124,7 +203,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: (transactions: SubmittableExtrinsic<'rxjs'>[]) => + transactions.forEach((transaction) => transaction.signAndSend('')), + failure: parameters.batchTxFailure, }, }, }, @@ -140,6 +239,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: [ { @@ -479,7 +607,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 +622,418 @@ 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/)) + for (const account of accounts) { + await selectFromDropdown(modal, /^If your validator account/, account) + const addButton = document.getElementsByClassName('add-button')[0] + await userEvent.click(addButton) + } +} + +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({ + 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(NEW_MEMBER_DATA.id) + expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(1) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(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({ + 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(NEW_MEMBER_DATA.id) + expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(NEW_MEMBER_DATA.id, charlie.controllerAccount) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(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 validatorAddressInputElement = document.getElementById('select-validatorAccount-input') + expect(validatorAddressInputElement).not.toBeNull() + await userEvent.paste( + validatorAddressInputElement as HTMLElement, + '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY' + ) + + expect(modal.getByText('This account is neither a validator controller account nor a validator stash account.')) + const addButton = document.getElementsByClassName('add-button')[0] + expect(addButton).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/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/common/components/Modal/Modals.tsx b/packages/ui/src/common/components/Modal/Modals.tsx index bd2cc2af01..8fb3bb58c1 100644 --- a/packages/ui/src/common/components/Modal/Modals.tsx +++ b/packages/ui/src/common/components/Modal/Modals.tsx @@ -17,6 +17,16 @@ export const Row = styled.div` height: auto; ` +export const RowInline = styled.div<{ gap?: number; top?: number }>` + display: flex; + flex-direction: row; + width: 100%; + height: auto; + align-items: center; + 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/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/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/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 171938e776..8a1f7f2dd1 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx @@ -1,12 +1,14 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' import { BalanceOf } from '@polkadot/types/interfaces/runtime' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' +import styled from 'styled-components' import * as Yup from 'yup' -import { SelectAccount } from '@/accounts/components/SelectAccount' +import { SelectAccount, SelectedAccount } from '@/accounts/components/SelectAccount' import { useMyAccounts } from '@/accounts/hooks/useMyAccounts' import { accountOrNamed } from '@/accounts/model/accountOrNamed' +import { encodeAddress } from '@/accounts/model/encodeAddress' import { Account } from '@/accounts/types' import { TermsRoutes } from '@/app/constants/routes' import { ButtonGhost, ButtonPrimary } from '@/common/components/buttons' @@ -20,13 +22,15 @@ import { LabelLink, ToggleCheckbox, } from '@/common/components/forms' -import { Arrow } from '@/common/components/icons' +import { Arrow, CrossIcon, PlusIcon } from '@/common/components/icons' +import { AlertSymbol } from '@/common/components/icons/symbols' import { Loading } from '@/common/components/Loading' import { ModalFooter, ModalFooterGroup, ModalHeader, Row, + RowInline, ScrolledModal, ScrolledModalBody, ScrolledModalContainer, @@ -34,13 +38,14 @@ import { } from '@/common/components/Modal' import { Tooltip, TooltipDefault } from '@/common/components/Tooltip' import { TransactionInfo } from '@/common/components/TransactionInfo' -import { TextMedium } from '@/common/components/typography' +import { TextMedium, TextSmall } from '@/common/components/typography' import { definedValues } from '@/common/utils' import { useYupValidationResolver } from '@/common/utils/validation' import { AvatarInput } from '@/memberships/components/AvatarInput' import { SocialMediaSelector } from '@/memberships/components/SocialMediaSelector/SocialMediaSelector' import { useUploadAvatarAndSubmit } from '@/memberships/hooks/useUploadAvatarAndSubmit' import { useGetMembersCountQuery } from '@/memberships/queries' +import { useValidators } from '@/validators/hooks/useValidators' import { SelectMember } from '../../components/SelectMember' import { @@ -76,6 +81,7 @@ const CreateMemberSchema = Yup.object().shape({ ), hasTerms: Yup.boolean().required().oneOf([true]), isReferred: Yup.boolean(), + isValidator: Yup.boolean(), referrer: ReferrerSchema, externalResources: ExternalResourcesSchema, }) @@ -88,6 +94,9 @@ export interface MemberFormFields { about: string avatarUri: File | string | null isReferred?: boolean + isValidator?: boolean + validatorAccountCandidate?: Account + validatorAccounts?: Account[] referrer?: Member hasTerms?: boolean invitor?: Member @@ -101,6 +110,7 @@ const formDefaultValues = { about: '', avatarUri: null, isReferred: false, + isValidator: false, referrer: undefined, hasTerms: false, externalResources: {}, @@ -135,7 +145,30 @@ export const BuyMembershipForm = ({ }, }) - const [handle, isReferred, referrer, captchaToken] = form.watch(['handle', 'isReferred', 'referrer', 'captchaToken']) + const [handle, isReferred, isValidator, referrer, captchaToken, validatorAccountCandidate] = form.watch([ + 'handle', + 'isReferred', + 'isValidator', + 'referrer', + 'captchaToken', + 'validatorAccountCandidate', + ]) + + const { allValidators, allValidatorsWithCtrlAcc } = useValidators({ skip: isValidator ?? true }) + const [validatorAccounts, setValidatorAccounts] = useState([]) + const validatorAddresses = useMemo(() => { + if (!allValidatorsWithCtrlAcc || !allValidators) return + return ( + [...allValidatorsWithCtrlAcc, ...allValidators.map(({ address }) => address)].filter( + (element) => !!element + ) as string[] + ).map(encodeAddress) + }, [allValidators, allValidatorsWithCtrlAcc]) + + const isValidValidatorAccount = useMemo( + () => validatorAccountCandidate && validatorAddresses?.includes(encodeAddress(validatorAccountCandidate.address)), + [allValidators, allValidatorsWithCtrlAcc, validatorAddresses, validatorAccountCandidate] + ) useEffect(() => { if (handle) { @@ -149,10 +182,21 @@ export const BuyMembershipForm = ({ } }, [data?.membershipsConnection.totalCount]) - const isFormValid = !isUploading && form.formState.isValid + const isFormValid = !isUploading && form.formState.isValid && (!isValidator || validatorAccounts?.length) const isDisabled = type === 'onBoarding' && process.env.REACT_APP_CAPTCHA_SITE_KEY ? !captchaToken || !isFormValid : !isFormValid + const addValidatorAccount = () => { + if (validatorAccountCandidate && isValidValidatorAccount) { + setValidatorAccounts([...new Set([...validatorAccounts, validatorAccountCandidate])]) + form?.setValue('validatorAccountCandidate' as keyof MemberFormFields, undefined) + } + } + + const removeValidatorAccount = (index: number) => { + setValidatorAccounts([...validatorAccounts.slice(0, index), ...validatorAccounts.slice(index + 1)]) + } + return ( <> @@ -234,6 +278,79 @@ export const BuyMembershipForm = ({ + {type === 'general' && ( + <> + + + + + {isValidator && ( + <> + + + + + + + * + + + If your validator account is not in your signer wallet, paste the account address to the field + below: + + + + !!validatorAddresses?.includes(encodeAddress(account.address))} + /> + + + + + + {validatorAccountCandidate && !isValidValidatorAccount && ( + + + + + + + + This account is neither a validator controller account nor a validator stash account. + + + )} + + + {validatorAccounts.map((account, index) => ( + + + + { + removeValidatorAccount(index) + }} + > + + + + + ))} + + )} + + )} + {process.env.REACT_APP_CAPTCHA_SITE_KEY && type === 'onBoarding' && ( { + validatorAccounts?.map((account, index) => { + form?.register(('validatorAccounts[' + index + ']') as keyof MemberFormFields) + form?.setValue(('validatorAccounts[' + index + ']') as keyof MemberFormFields, account) + }) const values = form.getValues() uploadAvatarAndSubmit({ ...values, externalResources: { ...definedValues(values.externalResources) } }) }} @@ -308,3 +429,25 @@ export const BuyMembershipFormModal = ({ onClose, onSubmit, membershipPrice }: B ) } + +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/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..86577ba0ef 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx @@ -70,9 +70,28 @@ export const BuyMembershipSignModal = ({ const signDisabled = !isReady || !hasFunds || !validationInfo return ( - + - You intend to create a new membership. + + {formData.isValidator + ? 'You intend to create a validator membership.' + : 'You intend to create a new membership.'} + The creation of the new membership costs . @@ -108,7 +127,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..44eae36580 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?.isValidator, + }, { target: 'success', actions: assign({ memberId: (context, event) => getDataFromEvent(event.data.events, 'members', 'MembershipBought', 0), }), + cond: (context, event) => isTransactionSuccess(context, event) && !context.form?.isValidator, + }, + { + 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/types.ts b/packages/ui/src/memberships/modals/UpdateMembershipModal/types.ts index de145adda7..d61ace6c53 100644 --- a/packages/ui/src/memberships/modals/UpdateMembershipModal/types.ts +++ b/packages/ui/src/memberships/modals/UpdateMembershipModal/types.ts @@ -1,4 +1,5 @@ import { Account } from '@/accounts/types' +import { Address } from '@/common/types' export interface UpdateMemberForm { id: string @@ -9,4 +10,7 @@ export interface UpdateMemberForm { rootAccount?: Account controllerAccount?: Account externalResources: Record + isValidator?: boolean + validatorAccountCandidate?: Account + validatorAccounts?: Address[] } 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..cbbdcdcad7 --- /dev/null +++ b/packages/ui/src/validators/hooks/useValidators.ts @@ -0,0 +1,16 @@ +import { useContext, useEffect } from 'react' + +import { ValidatorsContext } from '../providers/context' + +type Props = { skip?: boolean } + +export const useValidators = ({ skip = false }: Props = {}) => { + const { setShouldFetchValidators, allValidators, allValidatorsWithCtrlAcc, validatorsWithMembership } = + useContext(ValidatorsContext) + + useEffect(() => { + if (!skip) setShouldFetchValidators(true) + }, []) + + return { allValidators, allValidatorsWithCtrlAcc, validatorsWithMembership } +} diff --git a/packages/ui/src/validators/hooks/useValidatorsList.tsx b/packages/ui/src/validators/hooks/useValidatorsList.tsx index 06f7a3e6aa..24ffaa8598 100644 --- a/packages/ui/src/validators/hooks/useValidatorsList.tsx +++ b/packages/ui/src/validators/hooks/useValidatorsList.tsx @@ -12,7 +12,7 @@ import { last } from '@/common/utils' import { Verification, State, ValidatorWithDetails, ValidatorMembership } from '../types' -import { useValidatorMembers } from './useValidatorMembers' +import { useValidators } from './useValidators' export const useValidatorsList = () => { const { api } = useApi() @@ -20,7 +20,7 @@ export const useValidatorsList = () => { const [isVerified, setIsVerified] = useState(null) const [isActive, setIsActive] = useState(null) const [visibleValidators, setVisibleValidators] = useState([]) - const validators = useValidatorMembers() + const { validatorsWithMembership: validators } = useValidators() const validatorRewardPointsHistory = useFirstObservableValue( () => api?.query.staking.erasRewardPoints.entries(), diff --git a/packages/ui/src/validators/providers/context.tsx b/packages/ui/src/validators/providers/context.tsx new file mode 100644 index 0000000000..37d0aaf735 --- /dev/null +++ b/packages/ui/src/validators/providers/context.tsx @@ -0,0 +1,5 @@ +import { createContext } from 'react' + +import { UseValidators } from './provider' + +export const ValidatorsContext = createContext({ setShouldFetchValidators: () => {} }) diff --git a/packages/ui/src/validators/providers/provider.tsx b/packages/ui/src/validators/providers/provider.tsx new file mode 100644 index 0000000000..6993589c72 --- /dev/null +++ b/packages/ui/src/validators/providers/provider.tsx @@ -0,0 +1,121 @@ +import React, { ReactNode, useMemo, useState } from 'react' +import { map } from 'rxjs' + +import { useApi } from '@/api/hooks/useApi' +import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' +import { Address } from '@/common/types' +import { perbillToPercent } from '@/common/utils' +import { useGetMembersWithDetailsQuery } from '@/memberships/queries' +import { asMemberWithDetails } from '@/memberships/types' + +import { ValidatorMembership } from '../types' + +import { ValidatorsContext } from './context' + +interface Props { + children: ReactNode +} + +export interface UseValidators { + setShouldFetchValidators: (fetchValidators: boolean) => void + allValidators?: { + address: Address + commission: number + }[] + allValidatorsWithCtrlAcc?: (string | undefined)[] + validatorsWithMembership?: ValidatorMembership[] +} + +export const ValidatorContextProvider = (props: Props) => { + const { api } = useApi() + + const [shouldFetchValidators, setShouldFetchValidators] = useState(false) + + const allValidators = useFirstObservableValue(() => { + if (!shouldFetchValidators) return undefined + return 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, shouldFetchValidators]) + + 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: { + OR: [ + { + rootAccount_in: + (allValidatorsWithCtrlAcc + ?.concat(allValidators?.map(({ address }) => address)) + .filter((element) => !!element) as string[]) ?? [], + }, + { + controllerAccount_in: + (allValidatorsWithCtrlAcc + ?.concat(allValidators?.map(({ address }) => address)) + .filter((element) => !!element) as string[]) ?? [], + }, + { + 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.rootAccount === address || + membership.rootAccount === controllerAccount || + membership.controllerAccount === address || + membership.controllerAccount === controllerAccount || + membership.boundAccounts.includes(address) || + (controllerAccount && membership.boundAccounts.includes(controllerAccount)) + ), + } + }) + ) + }, [data, allValidators, allValidatorsWithCtrlAcc]) + + const value = { + setShouldFetchValidators, + allValidators, + allValidatorsWithCtrlAcc, + validatorsWithMembership, + } + + return {props.children} +} From e89ffb96026613e2b69d7afe3f178375de5b752c Mon Sep 17 00:00:00 2001 From: eshark9312 <129978066+eshark9312@users.noreply.github.com> Date: Fri, 5 Jan 2024 10:10:18 -0800 Subject: [PATCH 02/14] Validator page responsiveness (#4706) * Revert "remove route, sidebar item, tab, dashboard, modal" This reverts commit 480cd09c13e5fae383af36b86060620dffcce043. * Fix the validator hooks * fix validator page tab * add responsive * fix * lint fix * fix hook * add loading, count the current era into uptime * lint fix * remove validator dashboard * address merge conflict * fix validatorsInfo modal * fix validatorslist col-layout * update validator provider * fix * lint fix * fix --- packages/ui/src/app/App.tsx | 3 + packages/ui/src/app/components/SideBar.tsx | 6 + .../app/pages/Validators/ValidatorList.tsx | 21 ++- .../app/pages/Validators/ValidatorsModule.tsx | 18 +++ .../Validators/components/ValidatorsTabs.tsx | 11 ++ packages/ui/src/mocks/helpers/asChainData.ts | 1 + .../validators/components/ValidatorItem.tsx | 2 +- .../components/ValidatorsFilter.tsx | 11 ++ .../validators/components/ValidatorsList.tsx | 147 ++++++++++-------- .../ui/src/validators/constants/routes.tsx | 10 ++ .../validators/hooks/useValidatorsList.tsx | 90 ++--------- .../src/validators/modals/ValidatorsInfo.tsx | 76 +++++++++ .../modals/validatorCard/ValidatorDetail.tsx | 2 +- .../ui/src/validators/providers/context.tsx | 5 +- .../ui/src/validators/providers/provider.tsx | 84 +++++++++- 15 files changed, 333 insertions(+), 154 deletions(-) create mode 100644 packages/ui/src/app/pages/Validators/ValidatorsModule.tsx create mode 100644 packages/ui/src/app/pages/Validators/components/ValidatorsTabs.tsx create mode 100644 packages/ui/src/validators/constants/routes.tsx create mode 100644 packages/ui/src/validators/modals/ValidatorsInfo.tsx 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/components/SideBar.tsx b/packages/ui/src/app/components/SideBar.tsx index be0d1495ef..3f0450b188 100644 --- a/packages/ui/src/app/components/SideBar.tsx +++ b/packages/ui/src/app/components/SideBar.tsx @@ -39,6 +39,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' @@ -131,6 +132,11 @@ export const SideBarContent = () => { + + }> + Validators + + }> Settings diff --git a/packages/ui/src/app/pages/Validators/ValidatorList.tsx b/packages/ui/src/app/pages/Validators/ValidatorList.tsx index 9058c69bc8..dd7b4cd87b 100644 --- a/packages/ui/src/app/pages/Validators/ValidatorList.tsx +++ b/packages/ui/src/app/pages/Validators/ValidatorList.tsx @@ -1,5 +1,7 @@ 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' @@ -12,6 +14,7 @@ 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 { eraStartedOn, @@ -34,7 +37,9 @@ export const ValidatorList = () => { - + } /> + + { /> - + } @@ -56,3 +61,15 @@ export const ValidatorList = () => { /> ) } + +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/mocks/helpers/asChainData.ts b/packages/ui/src/mocks/helpers/asChainData.ts index d0d3780675..dba8f9b5fb 100644 --- a/packages/ui/src/mocks/helpers/asChainData.ts +++ b/packages/ui/src/mocks/helpers/asChainData.ts @@ -26,6 +26,7 @@ const withUnwrap = (data: Record) => Object.defineProperties(data, { unwrap: { value: () => data }, isSome: { value: Object.keys(data).length > 0 }, + toJSON: { value: () => data }, get: { value: (key: any) => { if (key.toRawType?.() === 'AccountId') { diff --git a/packages/ui/src/validators/components/ValidatorItem.tsx b/packages/ui/src/validators/components/ValidatorItem.tsx index c09e648a1d..cca84267f3 100644 --- a/packages/ui/src/validators/components/ValidatorItem.tsx +++ b/packages/ui/src/validators/components/ValidatorItem.tsx @@ -69,7 +69,7 @@ const ValidatorItemWrapper = styled.div` export const ValidatorItemWrap = styled.div` display: grid; - grid-template-columns: 250px 100px 80px 120px 120px 140px 100px 90px; + grid-template-columns: 250px 110px 80px 140px 140px 140px 100px 90px; grid-template-rows: 1fr; justify-content: space-between; justify-items: start; diff --git a/packages/ui/src/validators/components/ValidatorsFilter.tsx b/packages/ui/src/validators/components/ValidatorsFilter.tsx index 33e9987629..b4c365488a 100644 --- a/packages/ui/src/validators/components/ValidatorsFilter.tsx +++ b/packages/ui/src/validators/components/ValidatorsFilter.tsx @@ -63,9 +63,20 @@ const SelectFields = styled.div` * { width: 184px; } + + @media (max-width: 767px) { + flex-direction: column; + * { + width: 100%; + } + } ` const Fields = styled.div` display: flex; justify-content: space-between; gap: 8px; + + @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..c5b61ce1d9 100644 --- a/packages/ui/src/validators/components/ValidatorsList.tsx +++ b/packages/ui/src/validators/components/ValidatorsList.tsx @@ -5,6 +5,7 @@ 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 { Loading } from '@/common/components/Loading' import { Tooltip, TooltipDefault } from '@/common/components/Tooltip' import { Colors } from '@/common/constants' import { Comparator } from '@/common/model/Comparator' @@ -42,76 +43,90 @@ export const ValidatorsList = ({ validators }: ValidatorsListProps) => { } } + if (validators.length === 0) return return ( - - - onSort('stashAccount')} - isActive={sortBy === 'stashAccount'} - isDescending={isDescending} - > - Validator - - - Verification - + + + onSort('stashAccount')} + isActive={sortBy === 'stashAccount'} + isDescending={isDescending} > - - - - 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 -

- } + 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} > - - - - onSort('commission', true)} - isActive={sortBy === 'commission'} - isDescending={isDescending} - > - Commission - -
- - {sortedValidators?.map((validator, index) => ( - { - selectCard(index + 1) - }} - > - - - ))} - - {cardNumber && sortedValidators[cardNumber - 1] && ( - - )} -
+ Commission + + + + {sortedValidators?.map((validator, index) => ( + { + selectCard(index + 1) + }} + > + + + ))} + + {cardNumber && sortedValidators[cardNumber - 1] && ( + + )} + + ) } +const ResponsiveWrap = styled.div` + overflow: auto; + max-width: calc(100vw - 32px); + @media (min-width: 768px) { + max-width: calc(100vw - 48px); + } + @media (min-width: 1024px) { + max-width: calc(100vw - 274px); + } +` + const ValidatorsListWrap = styled.div` display: grid; grid-template-columns: 1fr; @@ -120,7 +135,7 @@ const ValidatorsListWrap = styled.div` 'validatorstablenav' 'validatorslist'; grid-row-gap: 4px; - width: 100%; + min-width: 977px; ${List} { gap: 8px; @@ -134,7 +149,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/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/useValidatorsList.tsx b/packages/ui/src/validators/hooks/useValidatorsList.tsx index 24ffaa8598..e42a67b7ae 100644 --- a/packages/ui/src/validators/hooks/useValidatorsList.tsx +++ b/packages/ui/src/validators/hooks/useValidatorsList.tsx @@ -1,94 +1,26 @@ -import { BN } from '@polkadot/util' -import { useEffect, useState } from 'react' -import { of, map, switchMap, Observable, combineLatest } from 'rxjs' +import { useContext, useEffect, 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 { Verification, State, ValidatorWithDetails, ValidatorMembership } from '../types' - -import { useValidators } from './useValidators' +import { ValidatorsContext } from '../providers/context' +import { Verification, State, ValidatorWithDetails } from '../types' 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 { validatorsWithMembership: validators } = useValidators() - - const validatorRewardPointsHistory = useFirstObservableValue( - () => api?.query.staking.erasRewardPoints.entries(), - [api?.isConnected] - ) - 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 { setShouldFetchValidators, setShouldFetchExtraDetails, validatorsWithDetails } = 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] - ) + useEffect(() => { + setShouldFetchValidators(true) + setShouldFetchExtraDetails(true) + }, []) useEffect(() => { - if (allValidatorsWithDetails) { + if (validatorsWithDetails) { setVisibleValidators( - allValidatorsWithDetails + validatorsWithDetails .filter((validator) => { if (isActive === 'active') return validator.isActive else if (isActive === 'waiting') return !validator.isActive @@ -106,7 +38,7 @@ export const useValidatorsList = () => { }) ) } - }, [allValidatorsWithDetails, search, isVerified, isActive]) + }, [validatorsWithDetails, search, isVerified, isActive]) return { visibleValidators, diff --git a/packages/ui/src/validators/modals/ValidatorsInfo.tsx b/packages/ui/src/validators/modals/ValidatorsInfo.tsx new file mode 100644 index 0000000000..325a864c05 --- /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/ValidatorDetail.tsx b/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx index 559acb693b..c70ebd5ad4 100644 --- a/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx +++ b/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx @@ -49,7 +49,7 @@ export const ValidatorDetail = ({ validator, hideModal }: Props) => { rewardPoints).length / ERA_DEPTH) * + (validator.rewardPointsHistory.filter(({ rewardPoints }) => rewardPoints).length / (ERA_DEPTH + 1)) * 100 ).toFixed(3)}%`} > diff --git a/packages/ui/src/validators/providers/context.tsx b/packages/ui/src/validators/providers/context.tsx index 37d0aaf735..19a080f16a 100644 --- a/packages/ui/src/validators/providers/context.tsx +++ b/packages/ui/src/validators/providers/context.tsx @@ -2,4 +2,7 @@ import { createContext } from 'react' import { UseValidators } from './provider' -export const ValidatorsContext = createContext({ setShouldFetchValidators: () => {} }) +export const ValidatorsContext = createContext({ + setShouldFetchValidators: () => {}, + setShouldFetchExtraDetails: () => {}, +}) diff --git a/packages/ui/src/validators/providers/provider.tsx b/packages/ui/src/validators/providers/provider.tsx index 6993589c72..a6dc5e3c01 100644 --- a/packages/ui/src/validators/providers/provider.tsx +++ b/packages/ui/src/validators/providers/provider.tsx @@ -1,14 +1,17 @@ +import { BN } from '@polkadot/util' import React, { ReactNode, useMemo, useState } from 'react' -import { map } from 'rxjs' +import { of, map, switchMap, Observable, combineLatest } from 'rxjs' +import { Api } from '@/api' import { useApi } from '@/api/hooks/useApi' +import { ERAS_PER_YEAR } from '@/common/constants' import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' import { Address } from '@/common/types' -import { perbillToPercent } from '@/common/utils' +import { perbillToPercent, last } from '@/common/utils' import { useGetMembersWithDetailsQuery } from '@/memberships/queries' import { asMemberWithDetails } from '@/memberships/types' -import { ValidatorMembership } from '../types' +import { ValidatorMembership, ValidatorWithDetails } from '../types' import { ValidatorsContext } from './context' @@ -17,19 +20,22 @@ interface Props { } export interface UseValidators { - setShouldFetchValidators: (fetchValidators: boolean) => void + setShouldFetchValidators: (shouldFetchValidators: boolean) => void + setShouldFetchExtraDetails: (shouldFetchExtraDetails: boolean) => void allValidators?: { address: Address commission: number }[] allValidatorsWithCtrlAcc?: (string | undefined)[] validatorsWithMembership?: ValidatorMembership[] + validatorsWithDetails?: ValidatorWithDetails[] } export const ValidatorContextProvider = (props: Props) => { const { api } = useApi() const [shouldFetchValidators, setShouldFetchValidators] = useState(false) + const [shouldFetchExtraDetails, setShouldFetchExtraDetails] = useState(false) const allValidators = useFirstObservableValue(() => { if (!shouldFetchValidators) return undefined @@ -110,11 +116,81 @@ export const ValidatorContextProvider = (props: Props) => { ) }, [data, allValidators, allValidatorsWithCtrlAcc]) + const validatorRewardPointsHistory = useFirstObservableValue(() => { + if (!shouldFetchExtraDetails) return + return api?.query.staking.erasRewardPoints.entries() + }, [api?.isConnected, shouldFetchExtraDetails]) + + const activeValidators = useFirstObservableValue(() => { + if (!shouldFetchExtraDetails) return + return api?.query.session.validators() + }, [api?.isConnected, shouldFetchExtraDetails]) + + 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.toJSON()[address] ?? 0) as number, + })) + return { + ...validator, + isActive: activeValidators.includes(address), + totalRewards: rewardHistory.reduce((total: BN, data) => total.add(data.eraReward), new BN(0)), + rewardPointsHistory, + APR: apr, + slashed: slashingSpans.isSome + ? slashingSpans.unwrap().prior.length + (slashingSpans.unwrap().lastNonzeroSlash.toNumber() > 0 ? 1 : 0) + : 0, + staking: { + total: stakingInfo.total.toBn(), + own: stakingInfo.own.toBn(), + others: stakingInfo.others.map((nominator) => ({ + address: nominator.who.toString(), + staking: nominator.value.toBn(), + })), + }, + } + }) + ) + } + + const getValidatorsInfo = (api: Api, validators: ValidatorMembership[]) => { + const validatorInfoObservables = validators.map((validator) => getValidatorInfo(validator, api)) + return combineLatest(validatorInfoObservables) + } + + const validatorsWithDetails = useFirstObservableValue( + () => + api && validatorsWithMembership && validatorRewardPointsHistory && activeValidators + ? getValidatorsInfo(api, validatorsWithMembership) + : of([]), + [api?.isConnected, validatorsWithMembership, validatorRewardPointsHistory, activeValidators] + ) + const value = { setShouldFetchValidators, + setShouldFetchExtraDetails, allValidators, allValidatorsWithCtrlAcc, validatorsWithMembership, + validatorsWithDetails, } return {props.children} From 1deafe80cc4f02fcb5285b583f96e98c1298c388 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Mon, 15 Jan 2024 17:51:58 +0100 Subject: [PATCH 03/14] =?UTF-8?q?=F0=9F=9A=BF=20Fix=20Validator=20dashboar?= =?UTF-8?q?d=20queries=20(#4718)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Make validators `APR`, `staking`, and `slashed` optional * Make `totalRewards` and `rewardPointsHistory` optional too * Fix the validator details queries * Filter validator queries * Fix filters * Order validators query results * Skip the membership query when details aren't needed * Add pagination * Rename "others" to "nominators" * Fix the tests * Filter, sort, and paginate on the last step * Split the provider code * Fix pagination on filtered results --- .../Validators/ValidatorList.stories.tsx | 162 ++++----------- .../app/pages/Validators/ValidatorList.tsx | 4 +- .../components/Pagination/Pagination.tsx | 2 +- .../forms/FilterBox/FilterSearchBox.tsx | 8 +- .../components/statistics/TokenValueStat.tsx | 2 +- .../components/typography/NotFoundText.tsx | 3 +- packages/ui/src/common/constants/numbers.ts | 3 +- .../BuyMembershipFormModal.tsx | 19 +- packages/ui/src/mocks/providers/api.tsx | 15 ++ .../validators/components/ValidatorItem.tsx | 7 +- .../components/ValidatorsFilter.tsx | 29 +-- .../validators/components/ValidatorsList.tsx | 182 +++++++++-------- .../ui/src/validators/hooks/useValidators.ts | 5 +- .../validators/hooks/useValidatorsList.tsx | 78 ++++---- .../modals/validatorCard/Nominators.tsx | 4 +- .../modals/validatorCard/ValidatorDetail.tsx | 38 ++-- .../ui/src/validators/providers/context.tsx | 2 +- .../ui/src/validators/providers/provider.tsx | 181 +++-------------- .../providers/useValidatorsWithDetails.ts | 187 ++++++++++++++++++ packages/ui/src/validators/providers/utils.ts | 129 ++++++++++++ packages/ui/src/validators/types/Validator.ts | 45 +++-- packages/ui/src/validators/types/index.ts | 3 - 22 files changed, 615 insertions(+), 493 deletions(-) create mode 100644 packages/ui/src/validators/providers/useValidatorsWithDetails.ts create mode 100644 packages/ui/src/validators/providers/utils.ts diff --git a/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx b/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx index e32ce40fb8..8ef4d51199 100644 --- a/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx +++ b/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx @@ -2,7 +2,6 @@ import { expect } from '@storybook/jest' import { Meta, StoryObj } from '@storybook/react' import { userEvent, waitFor, within } from '@storybook/testing-library' -import { Address } from '@/common/types' import { GetMembersWithDetailsDocument } from '@/memberships/queries' import { member } from '@/mocks/data/members' import { joy, selectFromDropdown } from '@/mocks/helpers' @@ -12,6 +11,21 @@ import { ValidatorList } from './ValidatorList' type Args = object +const eraRewardEntries = [ + [688, joy(0.123456)], + [689, joy(0.123456)], + [690, joy(0.123456)], + [691, joy(0.123456)], + [692, joy(0.123456)], + [693, joy(0.123456)], + [694, joy(0.123456)], + [695, joy(0.123456)], + [696, joy(0.123456)], + [697, joy(0.123456)], + [698, joy(0.123456)], + [699, joy(0.123456)], +] as const + export default { title: 'Pages/Validators/ValidatorList', component: ValidatorList, @@ -21,72 +35,7 @@ export default { return { 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 ?? [] - }, - }, + staking: { erasRewards: eraRewardEntries.map(([era, eraReward]) => ({ era, eraReward })) }, }, query: { balances: { @@ -100,10 +49,6 @@ export default { 'j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP', 'j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz', 'j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa', - 'j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN', - 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', - 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', - 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', ], }, staking: { @@ -122,8 +67,6 @@ export default { 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', - 'j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt', - 'j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM', ], }, counterForValidators: 12, @@ -145,8 +88,6 @@ export default { 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), }, }, ], @@ -161,8 +102,6 @@ export default { 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), }, }, ], @@ -179,7 +118,6 @@ export default { 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), }, }, ], @@ -196,7 +134,6 @@ export default { 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), }, }, ], @@ -214,7 +151,6 @@ export default { 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), }, }, ], @@ -232,8 +168,6 @@ export default { 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), }, }, ], @@ -248,8 +182,6 @@ export default { 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), }, }, ], @@ -263,8 +195,6 @@ export default { 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), }, }, ], @@ -282,7 +212,6 @@ export default { 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), }, }, ], @@ -300,7 +229,6 @@ export default { 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), }, }, ], @@ -318,8 +246,6 @@ export default { 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), }, }, ], @@ -336,14 +262,12 @@ export default { 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), + erasValidatorReward: new Map(eraRewardEntries), erasStakers: { total: joy(400), own: joy(0.0001), @@ -355,12 +279,10 @@ export default { { 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) }, @@ -414,14 +336,6 @@ export default { { 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 }, - ], ], }, }, @@ -459,7 +373,7 @@ export const TestsFilters: Story = { expect(screen.getByText('alice')) expect(screen.queryByText('bob')).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')) @@ -467,10 +381,10 @@ export const TestsFilters: Story = { }) 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') }) @@ -485,7 +399,7 @@ export const TestsFilters: Story = { 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')) @@ -497,35 +411,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(1)) 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 dd7b4cd87b..0707065435 100644 --- a/packages/ui/src/app/pages/Validators/ValidatorList.tsx +++ b/packages/ui/src/app/pages/Validators/ValidatorList.tsx @@ -31,7 +31,7 @@ export const ValidatorList = () => { acitveNominatorsCount, allNominatorsCount, } = useStakingStatistics() - const { visibleValidators, filter } = useValidatorsList() + const { validatorsWithDetails, pagination, order, filter } = useValidatorsList() return ( { } - main={} + main={} /> ) } 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/forms/FilterBox/FilterSearchBox.tsx b/packages/ui/src/common/components/forms/FilterBox/FilterSearchBox.tsx index b4123c3664..a9b20766ea 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' @@ -41,13 +40,10 @@ interface SearchBoxProps extends ControlProps { displayReset?: boolean } export const SearchBox = React.memo(({ value, onApply, onChange, label, displayReset }: SearchBoxProps) => { - const debouncedValue = useDebounce(value, 300) const change = onChange && (({ target }: ChangeEvent) => onChange(target.value)) - const isValid = () => !debouncedValue || debouncedValue.length === 0 || debouncedValue.length > 2 + const isValid = () => !value || value.length === 0 || value.length > 2 const keyDown = - !isValid() || !debouncedValue || !onApply - ? undefined - : ({ key }: React.KeyboardEvent) => key === 'Enter' && onApply() + !isValid() || !value || !onApply ? undefined : ({ key }: React.KeyboardEvent) => key === 'Enter' && onApply() const reset = onChange && onApply && 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..47decdb2aa 100644 --- a/packages/ui/src/common/constants/numbers.ts +++ b/packages/ui/src/common/constants/numbers.ts @@ -6,5 +6,6 @@ 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 ERAS_PER_YEAR = ERAS_PER_DAY * 365 export const ERA_DEPTH = 120 diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx index 8a1f7f2dd1..f8d42a4231 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx @@ -154,20 +154,19 @@ export const BuyMembershipForm = ({ 'validatorAccountCandidate', ]) - const { allValidators, allValidatorsWithCtrlAcc } = useValidators({ skip: isValidator ?? true }) + const validators = useValidators({ skip: isValidator ?? true }) const [validatorAccounts, setValidatorAccounts] = useState([]) - const validatorAddresses = useMemo(() => { - if (!allValidatorsWithCtrlAcc || !allValidators) return - return ( - [...allValidatorsWithCtrlAcc, ...allValidators.map(({ address }) => address)].filter( - (element) => !!element - ) as string[] - ).map(encodeAddress) - }, [allValidators, allValidatorsWithCtrlAcc]) + const validatorAddresses = useMemo( + () => + validators + ?.flatMap(({ stashAccount: stash, controllerAccount: ctrl }) => (ctrl ? [stash, ctrl] : [stash])) + .map(encodeAddress), + [validators] + ) const isValidValidatorAccount = useMemo( () => validatorAccountCandidate && validatorAddresses?.includes(encodeAddress(validatorAccountCandidate.address)), - [allValidators, allValidatorsWithCtrlAcc, validatorAddresses, validatorAccountCandidate] + [validatorAccountCandidate, validatorAddresses] ) useEffect(() => { diff --git a/packages/ui/src/mocks/providers/api.tsx b/packages/ui/src/mocks/providers/api.tsx index 21f251c6a0..02ea76dc06 100644 --- a/packages/ui/src/mocks/providers/api.tsx +++ b/packages/ui/src/mocks/providers/api.tsx @@ -123,6 +123,21 @@ const asApiMethod = (value: any) => { 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()) { + 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)) diff --git a/packages/ui/src/validators/components/ValidatorItem.tsx b/packages/ui/src/validators/components/ValidatorItem.tsx index cca84267f3..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}% 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,12 +22,17 @@ 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' : undefined + const stateValue = isActive === true ? 'active' : isActive === false ? 'waiting' : undefined + 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 @@ -40,14 +43,14 @@ export const ValidatorsFilter = ({ filter }: ValidatorFilterProps) => { filter.setIsVerified(value === null ? undefined : value === 'verified')} /> filter.setIsActive(value === null ? undefined : value === 'active')} /> diff --git a/packages/ui/src/validators/components/ValidatorsList.tsx b/packages/ui/src/validators/components/ValidatorsList.tsx index c5b61ce1d9..9564f2ab98 100644 --- a/packages/ui/src/validators/components/ValidatorsList.tsx +++ b/packages/ui/src/validators/components/ValidatorsList.tsx @@ -1,4 +1,5 @@ -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' @@ -6,125 +7,120 @@ import { List, ListItem } from '@/common/components/List' import { ListHeader } from '@/common/components/List/ListHeader' import { SortHeader } from '@/common/components/List/SortHeader' import { Loading } from '@/common/components/Loading' +import { Pagination, PaginationProps } from '@/common/components/Pagination' import { Tooltip, TooltipDefault } from '@/common/components/Tooltip' +import { NotFoundText } from '@/common/components/typography/NotFoundText' import { Colors } from '@/common/constants' -import { Comparator } from '@/common/model/Comparator' 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' interface ValidatorsListProps { - validators: ValidatorWithDetails[] + validators: ValidatorWithDetails[] | undefined + order: ValidatorDetailsOrder & { sortBy: (key: ValidatorDetailsOrder['key']) => () => void } + pagination: PaginationProps } -export const ValidatorsList = ({ validators }: ValidatorsListProps) => { +export const ValidatorsList = ({ validators, 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] - ) + if (!validators) return - const onSort = (key: SortKey, descendingByDefault = false) => { - if (key === sortBy) { - setDescending(!isDescending) - } else { - setDescending(descendingByDefault) - setSortBy(key) - } - } + if (!validators.length) return {t('common:forms.noResults')} - if (validators.length === 0) return 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) - }} + 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: Last reward extrapolated over a year times{' '} + The nominator commission divided by The total staked by the validator +

+ } + > + +
+
+ - -
- ))} -
- {cardNumber && sortedValidators[cardNumber - 1] && ( - - )} -
-
+ Commission + + + + {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: 768px) { max-width: calc(100vw - 48px); } - @media (min-width: 1024px) { - max-width: calc(100vw - 274px); - } ` const ValidatorsListWrap = styled.div` diff --git a/packages/ui/src/validators/hooks/useValidators.ts b/packages/ui/src/validators/hooks/useValidators.ts index cbbdcdcad7..695bc0bc30 100644 --- a/packages/ui/src/validators/hooks/useValidators.ts +++ b/packages/ui/src/validators/hooks/useValidators.ts @@ -5,12 +5,11 @@ import { ValidatorsContext } from '../providers/context' type Props = { skip?: boolean } export const useValidators = ({ skip = false }: Props = {}) => { - const { setShouldFetchValidators, allValidators, allValidatorsWithCtrlAcc, validatorsWithMembership } = - useContext(ValidatorsContext) + const { setShouldFetchValidators, validators } = useContext(ValidatorsContext) useEffect(() => { if (!skip) setShouldFetchValidators(true) }, []) - return { allValidators, allValidatorsWithCtrlAcc, validatorsWithMembership } + return validators } diff --git a/packages/ui/src/validators/hooks/useValidatorsList.tsx b/packages/ui/src/validators/hooks/useValidatorsList.tsx index e42a67b7ae..07d8ac4013 100644 --- a/packages/ui/src/validators/hooks/useValidatorsList.tsx +++ b/packages/ui/src/validators/hooks/useValidatorsList.tsx @@ -1,48 +1,56 @@ -import { useContext, useEffect, useState } from 'react' - -import { encodeAddress } from '@/accounts/model/encodeAddress' +import { useContext, useEffect, useMemo, useReducer, useState } from 'react' import { ValidatorsContext } from '../providers/context' -import { Verification, State, ValidatorWithDetails } from '../types' +import { ValidatorDetailsOrder } from '../types' + +const VALIDATOR_PER_PAGE = 7 +const DESCENDING_KEYS: ValidatorDetailsOrder['key'][] = ['commission'] export const useValidatorsList = () => { const [search, setSearch] = useState('') - const [isVerified, setIsVerified] = useState(null) - const [isActive, setIsActive] = useState(null) - const [visibleValidators, setVisibleValidators] = useState([]) - const { setShouldFetchValidators, setShouldFetchExtraDetails, validatorsWithDetails } = useContext(ValidatorsContext) + const [isVerified, setIsVerified] = useState() + const [isActive, setIsActive] = useState() + const filter = useMemo(() => ({ search, isVerified, isActive }), [search, isVerified, isActive]) - useEffect(() => { - setShouldFetchValidators(true) - setShouldFetchExtraDetails(true) - }, []) + 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 { + setShouldFetchValidators, + setValidatorDetailsOptions, + validatorsWithDetails, + size = 0, + } = useContext(ValidatorsContext) + + const [page, setPage] = useState(1) + const pagination = useMemo( + () => ({ + page, + handlePageChange: setPage, + pageCount: Math.ceil(size / VALIDATOR_PER_PAGE), + }), + [page, size] + ) useEffect(() => { - if (validatorsWithDetails) { - setVisibleValidators( - validatorsWithDetails - .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) - ) - }) - ) - } - }, [validatorsWithDetails, 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, + validatorsWithDetails, + pagination, + order: { ...order, sortBy: (key: ValidatorDetailsOrder['key']) => () => handleSort(key) }, filter: { search, setSearch, diff --git a/packages/ui/src/validators/modals/validatorCard/Nominators.tsx b/packages/ui/src/validators/modals/validatorCard/Nominators.tsx index a0b2eba801..d7c9df0f6d 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,7 +27,7 @@ export const Nominators = ({ validator }: Props) => { Total staked - {validator.staking.others?.map(({ address, staking }, index) => ( + {validator.staking?.nominators?.map(({ address, staking }, index) => ( diff --git a/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx b/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx index c70ebd5ad4..513e14f432 100644 --- a/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx +++ b/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx @@ -11,6 +11,7 @@ import { TextSmall } from '@/common/components/typography' import { BN_ZERO, ERA_DEPTH } 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' @@ -24,6 +25,12 @@ interface Props { export const ValidatorDetail = ({ validator, hideModal }: Props) => { const { showModal } = useModal() + const uptime = whenDefined( + validator.rewardPointsHistory, + (rewardPointsHistory) => + `${((rewardPointsHistory.filter(({ rewardPoints }) => rewardPoints).length / (ERA_DEPTH + 1)) * 100).toFixed(3)}%` + ) + return ( <> @@ -34,35 +41,34 @@ 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 + 1)) * - 100 - ).toFixed(3)}%`} - > + Uptime - -
Era points
- - - -
+ {validator.rewardPointsHistory && ( + +
Era points
+ + + +
+ )}
About
diff --git a/packages/ui/src/validators/providers/context.tsx b/packages/ui/src/validators/providers/context.tsx index 19a080f16a..a7073878a8 100644 --- a/packages/ui/src/validators/providers/context.tsx +++ b/packages/ui/src/validators/providers/context.tsx @@ -4,5 +4,5 @@ import { UseValidators } from './provider' export const ValidatorsContext = createContext({ setShouldFetchValidators: () => {}, - setShouldFetchExtraDetails: () => {}, + setValidatorDetailsOptions: () => {}, }) diff --git a/packages/ui/src/validators/providers/provider.tsx b/packages/ui/src/validators/providers/provider.tsx index a6dc5e3c01..f04cf27181 100644 --- a/packages/ui/src/validators/providers/provider.tsx +++ b/packages/ui/src/validators/providers/provider.tsx @@ -1,19 +1,14 @@ -import { BN } from '@polkadot/util' -import React, { ReactNode, useMemo, useState } from 'react' -import { of, map, switchMap, Observable, combineLatest } from 'rxjs' +import React, { ReactNode, useState } from 'react' +import { map } from 'rxjs' -import { Api } from '@/api' import { useApi } from '@/api/hooks/useApi' -import { ERAS_PER_YEAR } from '@/common/constants' import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' -import { Address } from '@/common/types' -import { perbillToPercent, last } from '@/common/utils' -import { useGetMembersWithDetailsQuery } from '@/memberships/queries' -import { asMemberWithDetails } from '@/memberships/types' +import { perbillToPercent } from '@/common/utils' -import { ValidatorMembership, ValidatorWithDetails } from '../types' +import { Validator, ValidatorWithDetails } from '../types' import { ValidatorsContext } from './context' +import { ValidatorDetailsOptions, useValidatorsWithDetails } from './useValidatorsWithDetails' interface Props { children: ReactNode @@ -21,176 +16,52 @@ interface Props { export interface UseValidators { setShouldFetchValidators: (shouldFetchValidators: boolean) => void - setShouldFetchExtraDetails: (shouldFetchExtraDetails: boolean) => void - allValidators?: { - address: Address - commission: number - }[] - allValidatorsWithCtrlAcc?: (string | undefined)[] - validatorsWithMembership?: ValidatorMembership[] + setValidatorDetailsOptions: (options: ValidatorDetailsOptions) => void + validators?: Validator[] validatorsWithDetails?: ValidatorWithDetails[] + size?: number } export const ValidatorContextProvider = (props: Props) => { const { api } = useApi() const [shouldFetchValidators, setShouldFetchValidators] = useState(false) - const [shouldFetchExtraDetails, setShouldFetchExtraDetails] = useState(false) const allValidators = useFirstObservableValue(() => { - if (!shouldFetchValidators) return undefined + if (!shouldFetchValidators) return + return api?.query.staking.validators.entries().pipe( map((entries) => entries.map((entry) => ({ - address: entry[0].args[0].toString(), + stashAccount: entry[0].args[0].toString(), commission: perbillToPercent(entry[1].commission.toBn()), })) ) ) }, [api?.isConnected, shouldFetchValidators]) - 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: { - OR: [ - { - rootAccount_in: - (allValidatorsWithCtrlAcc - ?.concat(allValidators?.map(({ address }) => address)) - .filter((element) => !!element) as string[]) ?? [], - }, - { - controllerAccount_in: - (allValidatorsWithCtrlAcc - ?.concat(allValidators?.map(({ address }) => address)) - .filter((element) => !!element) as string[]) ?? [], - }, - { - 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.rootAccount === address || - membership.rootAccount === controllerAccount || - membership.controllerAccount === address || - membership.controllerAccount === controllerAccount || - membership.boundAccounts.includes(address) || - (controllerAccount && membership.boundAccounts.includes(controllerAccount)) - ), - } - }) - ) - }, [data, allValidators, allValidatorsWithCtrlAcc]) - - const validatorRewardPointsHistory = useFirstObservableValue(() => { - if (!shouldFetchExtraDetails) return - return api?.query.staking.erasRewardPoints.entries() - }, [api?.isConnected, shouldFetchExtraDetails]) + const allValidatorsWithCtrlAcc = useFirstObservableValue(() => { + if (!allValidators) return - const activeValidators = useFirstObservableValue(() => { - if (!shouldFetchExtraDetails) return - return api?.query.session.validators() - }, [api?.isConnected, shouldFetchExtraDetails]) - - 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.toJSON()[address] ?? 0) as number, - })) - return { - ...validator, - isActive: activeValidators.includes(address), - totalRewards: rewardHistory.reduce((total: BN, data) => total.add(data.eraReward), new BN(0)), - rewardPointsHistory, - APR: apr, - slashed: slashingSpans.isSome - ? slashingSpans.unwrap().prior.length + (slashingSpans.unwrap().lastNonzeroSlash.toNumber() > 0 ? 1 : 0) - : 0, - staking: { - total: stakingInfo.total.toBn(), - own: stakingInfo.own.toBn(), - others: stakingInfo.others.map((nominator) => ({ - address: nominator.who.toString(), - staking: nominator.value.toBn(), - })), - }, - } - }) + 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 } + }) + ) ) - } - - const getValidatorsInfo = (api: Api, validators: ValidatorMembership[]) => { - const validatorInfoObservables = validators.map((validator) => getValidatorInfo(validator, api)) - return combineLatest(validatorInfoObservables) - } + }, [allValidators, api?.isConnected]) - const validatorsWithDetails = useFirstObservableValue( - () => - api && validatorsWithMembership && validatorRewardPointsHistory && activeValidators - ? getValidatorsInfo(api, validatorsWithMembership) - : of([]), - [api?.isConnected, validatorsWithMembership, validatorRewardPointsHistory, activeValidators] - ) + const { validatorsWithDetails, size, setValidatorDetailsOptions } = useValidatorsWithDetails(allValidatorsWithCtrlAcc) const value = { setShouldFetchValidators, - setShouldFetchExtraDetails, - allValidators, - allValidatorsWithCtrlAcc, - validatorsWithMembership, + setValidatorDetailsOptions, + validators: allValidatorsWithCtrlAcc, validatorsWithDetails, + size, } return {props.children} diff --git a/packages/ui/src/validators/providers/useValidatorsWithDetails.ts b/packages/ui/src/validators/providers/useValidatorsWithDetails.ts new file mode 100644 index 0000000000..ecb0a975f5 --- /dev/null +++ b/packages/ui/src/validators/providers/useValidatorsWithDetails.ts @@ -0,0 +1,187 @@ +import { useMemo, useState } from 'react' +import { combineLatest, map, Observable, of, ReplaySubject, share, switchMap, take } from 'rxjs' + +import { encodeAddress } from '@/accounts/model/encodeAddress' +import { useApi } from '@/api/hooks/useApi' +import { BN_ZERO } from '@/common/constants' +import { useObservable } from '@/common/hooks/useObservable' +import { isDefined } from '@/common/utils' +import { useGetMembersWithDetailsQuery } from '@/memberships/queries' +import { asMemberWithDetails } from '@/memberships/types' + +import { Validator, ValidatorDetailsFilter, ValidatorDetailsOrder, ValidatorWithDetails } from '../types' + +import { compareValidators, getValidatorsFilters, getValidatorInfo, filterValidatorsByIsActive } from './utils' + +export type ValidatorDetailsOptions = { + filter: ValidatorDetailsFilter + order: ValidatorDetailsOrder + start: number + end: number +} + +type AggregateResult = { + validators: ValidatorWithDetails[] + size: number +} + +export const useValidatorsWithDetails = (allValidatorsWithCtrlAcc: Validator[] | 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 validatorsRewards$ = useMemo(() => { + if (!api || !validatorDetailsOptions) return + + const eraPoints$ = api.query.staking.erasRewardPoints.entries() + const eraPayouts$ = api.query.staking.erasValidatorReward.entries() + + return combineLatest([eraPoints$, eraPayouts$]).pipe( + take(1), + map(([points, payouts]) => { + const payoutsMap = new Map(payouts.map(([era, amount]) => [era.args[0].toNumber(), amount.value.toBn()])) + + return points + .map((entry) => { + const era = entry[0].args[0].toNumber() + const totalPoints = entry[1].total.toNumber() + const individual = entry[1].individual.toJSON() as Record + const totalPayout = payoutsMap.get(era) ?? BN_ZERO + return { era, totalPoints, individual, totalPayout } + }) + .sort((a, b) => b.era - a.era) + .slice(1) // Remove the current period + }), + freezeObservable + ) + }, [api?.isConnected, !validatorDetailsOptions]) + + const activeValidators$ = useMemo(() => { + if (!validatorDetailsOptions) return + + return api?.query.session.validators().pipe( + take(1), + map((activeAccs) => activeAccs.map(encodeAddress)), + freezeObservable + ) + }, [api?.isConnected, !validatorDetailsOptions]) + + const aggregated = useObservable(() => { + if (!api || !validatorsWithMembership || !validatorsRewards$ || !activeValidators$ || !validatorDetailsOptions) { + return + } + + if (!validatorsWithMembership.length) return of({ validators: [], size: 0 }) + + const { filter, order, start, end } = validatorDetailsOptions + + const filterByState = switchMap( + (validators: ValidatorWithDetails[]): Observable => + isDefined(filter.isActive) + ? activeValidators$.pipe(filterValidatorsByIsActive(validators, filter.isActive)) + : of(validators) + ) + + const filterSortPaginate = map((validators: ValidatorWithDetails[]): AggregateResult => { + const filtered = getValidatorsFilters(filter).reduce( + (validators: ValidatorWithDetails[], predicate): ValidatorWithDetails[] => + predicate ? validators.filter(predicate) : validators, + validators + ) + + const sortedPaginated = filtered + .sort((a, b) => { + const direction = order.isDescending ? -1 : 1 + return direction * compareValidators(a, b, order.key) + }) + .slice(start, end) + + return { validators: sortedPaginated, size: filtered.length } + }) + + const getInfo = switchMap(({ validators, size }: AggregateResult): Observable => { + if (validators.length === 0) return of({ validators: [], size: 0 }) + + const withInfo = combineLatest( + validators.flatMap((validator) => { + const address = validator.stashAccount + + if (!validatorsWithDetailsCache.has(address)) { + const validator$ = getValidatorInfo(validator, activeValidators$, validatorsRewards$, api) + validatorsWithDetailsCache.set(address, validator$) + } + + return validatorsWithDetailsCache.get(address) as Observable + }) + ) + + return combineLatest({ validators: withInfo, size: of(size) }) + }) + + return of(validatorsWithMembership).pipe(filterByState, filterSortPaginate, getInfo) + }, [api?.isConnected, validatorsWithMembership, validatorsRewards$, activeValidators$, validatorDetailsOptions]) + + return { + validatorsWithDetails: aggregated?.validators, + size: aggregated?.size, + setValidatorDetailsOptions, + } +} + +const validatorsWithDetailsCache = new Map>() + +const freezeObservable: (o: Observable) => Observable = share({ + connector: () => new ReplaySubject(1), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, +}) diff --git a/packages/ui/src/validators/providers/utils.ts b/packages/ui/src/validators/providers/utils.ts new file mode 100644 index 0000000000..be44221e60 --- /dev/null +++ b/packages/ui/src/validators/providers/utils.ts @@ -0,0 +1,129 @@ +import BN from 'bn.js' +import { map, merge, Observable, of, ReplaySubject, scan, share, switchMap, take } from 'rxjs' + +import { Api } from '@/api' +import { BN_ZERO, ERAS_PER_YEAR } from '@/common/constants' +import { isDefined } from '@/common/utils' + +import { ValidatorDetailsFilter, ValidatorDetailsOrder, ValidatorWithDetails } from '../types' + +export const getValidatorsFilters = ({ isVerified, search = '' }: ValidatorDetailsFilter) => { + const s = search.toLowerCase() + const isMatch = (value: string | undefined) => value && value.toLowerCase().search(s) >= 0 + + return [ + // Verification filter + isDefined(isVerified) && ((v: ValidatorWithDetails) => !!v.isVerifiedValidator === isVerified), + + // Search filter + s.length > 2 && + (({ membership, stashAccount, controllerAccount }: ValidatorWithDetails) => + isMatch(membership?.handle) || isMatch(stashAccount) || isMatch(controllerAccount)), + ] +} + +export const filterValidatorsByIsActive = (validators: ValidatorWithDetails[], isActive: boolean) => + map((activeValidators: string[]) => + validators.filter(({ stashAccount }) => activeValidators.includes(stashAccount) === isActive) + ) + +export const compareValidators = ( + a: ValidatorWithDetails, + b: ValidatorWithDetails, + key: ValidatorDetailsOrder['key'] +) => { + switch (key) { + case 'default': { + 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 a.commission - b.commission + } +} + +type EraRewards = { + era: number + totalPoints: number + individual: Record + totalPayout: BN +} + +export const getValidatorInfo = ( + validator: ValidatorWithDetails, + activeValidators$: Observable, + validatorsRewards$: Observable, + api: Api +): Observable => { + const address = validator.stashAccount + + const status$ = activeValidators$.pipe(map((activeValidators) => ({ isActive: activeValidators.includes(address) }))) + + const rewards$ = validatorsRewards$.pipe( + map((allRewards) => { + const rewards = allRewards.flatMap(({ era, totalPoints, individual, totalPayout }) => { + if (!individual[address]) return [] + const eraPoints = Number(individual[address]) + const eraReward = totalPayout.muln(eraPoints / totalPoints) + return { era, eraReward, eraPoints } + }) + + return { + rewardPointsHistory: rewards.map(({ era, eraPoints }) => ({ era, rewardPoints: eraPoints })), + totalRewards: rewards.reduce((total, { eraReward }) => total.add(eraReward ?? BN_ZERO), BN_ZERO), + latestReward: rewards[0]?.eraReward, + } + }) + ) + + const stakes$ = api.query.staking.activeEra().pipe( + take(1), + switchMap((activeEra) => api.query.staking.erasStakers(activeEra.unwrap().index, address)), // TODO handle potential unwrap failure + map((stakingInfo) => { + 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 } } + }) + ) + + const slashing$ = api.query.staking.slashingSpans(address).pipe( + take(1), + map((slashingSpans) => { + if (!slashingSpans.isSome) return { slashed: 0 } + const { prior, lastNonzeroSlash } = slashingSpans.unwrap() + return { slashed: prior.length + (lastNonzeroSlash.gtn(0) ? 1 : 0) } + }) + ) + + return merge(of({}), status$, stakes$, rewards$, slashing$).pipe( + scan((validator: ValidatorWithDetails, part) => ({ ...part, ...validator }), validator), + map((validator) => { + const { commission, staking } = validator + if (!('latestReward' in validator) || !staking || staking.total.isZero()) return validator + + const latestReward = validator.latestReward as BN + const apr = Number(latestReward.muln(ERAS_PER_YEAR).muln(commission).div(staking.total)) + return { ...validator, APR: apr } + }), + share({ + connector: () => new ReplaySubject(1), + resetOnError: false, + resetOnComplete: false, + resetOnRefCountZero: false, + }) + ) +} diff --git a/packages/ui/src/validators/types/Validator.ts b/packages/ui/src/validators/types/Validator.ts index 448f47a6fd..c2e456af90 100644 --- a/packages/ui/src/validators/types/Validator.ts +++ b/packages/ui/src/validators/types/Validator.ts @@ -8,26 +8,35 @@ 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' + isDescending: boolean } 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' From 3dfd1861c8007f1c5684888249c0e65902f5b0d8 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Tue, 16 Jan 2024 13:04:07 +0100 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=93=A1=20Fix=20the=20queries=20brok?= =?UTF-8?q?en=20by=20the=20`ProxyApi`=20(#4733)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix address encoding * Fix the `query` function of `ProxyApi` * Same thing for tx: `paymentInfo` and `signAndSend` * Fix the no latest reward case * Don't encode the addresses * Fix tests --- .../ui/src/accounts/model/encodeAddress.ts | 4 +- packages/ui/src/app/App.stories.tsx | 5 +-- packages/ui/src/app/constants/chain.ts | 5 +++ packages/ui/src/app/constants/currency.ts | 4 +- .../Proposals/CurrentProposals.stories.tsx | 10 ++--- packages/ui/src/common/constants/numbers.ts | 4 +- packages/ui/src/common/model/createType.ts | 7 ++- packages/ui/src/proxyApi/client/query.ts | 44 ++++++++++--------- packages/ui/src/proxyApi/client/tx.ts | 34 +++++++------- .../providers/useValidatorsWithDetails.ts | 26 +++++------ packages/ui/src/validators/providers/utils.ts | 12 ++--- 11 files changed, 87 insertions(+), 68 deletions(-) create mode 100644 packages/ui/src/app/constants/chain.ts 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 3aaa81a2d7..81c6587c30 100644 --- a/packages/ui/src/app/App.stories.tsx +++ b/packages/ui/src/app/App.stories.tsx @@ -816,10 +816,7 @@ export const InvalidValidatorAccountInput: Story = { await userEvent.click(validatorCheckButton) const validatorAddressInputElement = document.getElementById('select-validatorAccount-input') expect(validatorAddressInputElement).not.toBeNull() - await userEvent.paste( - validatorAddressInputElement as HTMLElement, - '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY' - ) + 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 addButton = document.getElementsByClassName('add-button')[0] 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/Proposals/CurrentProposals.stories.tsx b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx index b9933de496..cfbea135a7 100644 --- a/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx +++ b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx @@ -777,7 +777,7 @@ export const SpecificParametersFundingRequest: Story = { step('Transaction parameters', () => { 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 +785,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 +838,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()) diff --git a/packages/ui/src/common/constants/numbers.ts b/packages/ui/src/common/constants/numbers.ts index 47decdb2aa..814106b0db 100644 --- a/packages/ui/src/common/constants/numbers.ts +++ b/packages/ui/src/common/constants/numbers.ts @@ -1,6 +1,8 @@ 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) 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 = ( ) => { 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/providers/useValidatorsWithDetails.ts b/packages/ui/src/validators/providers/useValidatorsWithDetails.ts index ecb0a975f5..91cd254aa9 100644 --- a/packages/ui/src/validators/providers/useValidatorsWithDetails.ts +++ b/packages/ui/src/validators/providers/useValidatorsWithDetails.ts @@ -1,7 +1,6 @@ import { useMemo, useState } from 'react' import { combineLatest, map, Observable, of, ReplaySubject, share, switchMap, take } from 'rxjs' -import { encodeAddress } from '@/accounts/model/encodeAddress' import { useApi } from '@/api/hooks/useApi' import { BN_ZERO } from '@/common/constants' import { useObservable } from '@/common/hooks/useObservable' @@ -86,7 +85,6 @@ export const useValidatorsWithDetails = (allValidatorsWithCtrlAcc: Validator[] | const eraPayouts$ = api.query.staking.erasValidatorReward.entries() return combineLatest([eraPoints$, eraPayouts$]).pipe( - take(1), map(([points, payouts]) => { const payoutsMap = new Map(payouts.map(([era, amount]) => [era.args[0].toNumber(), amount.value.toBn()])) @@ -101,18 +99,14 @@ export const useValidatorsWithDetails = (allValidatorsWithCtrlAcc: Validator[] | .sort((a, b) => b.era - a.era) .slice(1) // Remove the current period }), - freezeObservable + keepFirst ) }, [api?.isConnected, !validatorDetailsOptions]) const activeValidators$ = useMemo(() => { if (!validatorDetailsOptions) return - return api?.query.session.validators().pipe( - take(1), - map((activeAccs) => activeAccs.map(encodeAddress)), - freezeObservable - ) + return api?.query.session.validators().pipe(keepFirst) }, [api?.isConnected, !validatorDetailsOptions]) const aggregated = useObservable(() => { @@ -179,9 +173,13 @@ export const useValidatorsWithDetails = (allValidatorsWithCtrlAcc: Validator[] | const validatorsWithDetailsCache = new Map>() -const freezeObservable: (o: Observable) => Observable = share({ - connector: () => new ReplaySubject(1), - resetOnComplete: false, - resetOnError: false, - resetOnRefCountZero: false, -}) +const keepFirst = (o: Observable): Observable => + o.pipe( + take(1), + share({ + connector: () => new ReplaySubject(1), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) + ) diff --git a/packages/ui/src/validators/providers/utils.ts b/packages/ui/src/validators/providers/utils.ts index be44221e60..e307368f25 100644 --- a/packages/ui/src/validators/providers/utils.ts +++ b/packages/ui/src/validators/providers/utils.ts @@ -1,3 +1,5 @@ +import { Vec } from '@polkadot/types' +import { AccountId } from '@polkadot/types/interfaces' import BN from 'bn.js' import { map, merge, Observable, of, ReplaySubject, scan, share, switchMap, take } from 'rxjs' @@ -23,7 +25,7 @@ export const getValidatorsFilters = ({ isVerified, search = '' }: ValidatorDetai } export const filterValidatorsByIsActive = (validators: ValidatorWithDetails[], isActive: boolean) => - map((activeValidators: string[]) => + map((activeValidators: Vec) => validators.filter(({ stashAccount }) => activeValidators.includes(stashAccount) === isActive) ) @@ -61,7 +63,7 @@ type EraRewards = { export const getValidatorInfo = ( validator: ValidatorWithDetails, - activeValidators$: Observable, + activeValidators$: Observable>, validatorsRewards$: Observable, api: Api ): Observable => { @@ -109,14 +111,14 @@ export const getValidatorInfo = ( }) ) - return merge(of({}), status$, stakes$, rewards$, slashing$).pipe( + return merge(of({}), status$, rewards$, stakes$, slashing$).pipe( scan((validator: ValidatorWithDetails, part) => ({ ...part, ...validator }), validator), map((validator) => { const { commission, staking } = validator if (!('latestReward' in validator) || !staking || staking.total.isZero()) return validator - const latestReward = validator.latestReward as BN - const apr = Number(latestReward.muln(ERAS_PER_YEAR).muln(commission).div(staking.total)) + const latestReward = validator.latestReward + const apr = latestReward && Number(latestReward.muln(ERAS_PER_YEAR).muln(commission).div(staking.total)) return { ...validator, APR: apr } }), share({ From 87ccd022081880f3787258fdde75bc68f6af8095 Mon Sep 17 00:00:00 2001 From: eshark9312 <129978066+eshark9312@users.noreply.github.com> Date: Tue, 16 Jan 2024 12:14:02 -0800 Subject: [PATCH 05/14] Update membership (#4721) * add MyMembership storybook, update updateMembershipFormModal * fix machine * update UpdateMembershipModal * update storybook test * Run prettier * fix useValidators hook * update ValidatorsList storybook test * remove UpdateMembershipModal unit-test * fix proposal storybook test * remove UpdateMembershipModal unit-test * fix updateMembershipFormModal * Alice has two validator accounts * fix --- .../pages/Profile/MyMemberships.stories.tsx | 626 ++++++++++++++++++ .../Validators/ValidatorList.stories.tsx | 8 +- .../components/MemberListItem/MyMember.tsx | 2 +- .../BuyMembershipFormModal.tsx | 2 +- .../UpdateMembershipFormModal.tsx | 203 +++++- .../UpdateMembershipModal.tsx | 89 ++- .../modals/UpdateMembershipModal/machine.ts | 114 +++- .../modals/UpdateMembershipModal/types.ts | 1 + .../modals/UpdateMembershipModal/utils.ts | 18 +- packages/ui/src/mocks/data/raw/members.json | 9 +- .../ui/src/validators/hooks/useValidators.ts | 2 +- packages/ui/test/_mocks/keyring/signers.ts | 2 +- .../modals/UpdateMembershipModal.test.tsx | 210 ------ .../UpdateMembershipModalUtils.test.tsx | 129 ---- 14 files changed, 1025 insertions(+), 390 deletions(-) create mode 100644 packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx delete mode 100644 packages/ui/test/membership/modals/UpdateMembershipModal.test.tsx delete mode 100644 packages/ui/test/membership/modals/UpdateMembershipModalUtils.test.tsx 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..c38a147894 --- /dev/null +++ b/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx @@ -0,0 +1,626 @@ +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 { 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 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)], + hasWallet: true, + }, + chain: { + query: { + members: { membershipPrice: joy(20) }, + membershipWorkingGroup: { budget: joy(166666_66) }, + staking: { + bonded: { + multi: [ + 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', + 'j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW', + 'j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP', + 'j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz', + 'j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa', + 'j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN', + 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', + 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', + 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', + 'j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt', + 'j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM', + ], + }, + 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 }, + ], + ], + }, + }, + }, + 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: (transactions: SubmittableExtrinsic<'rxjs'>[]) => + transactions.forEach((transaction) => transaction.signAndSend('')), + 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')] }, + }, + { + 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[]) => { + for (const account of accounts) { + await selectFromDropdown(modal, /^If your validator account/, account) + const addButton = document.getElementsByClassName('add-button')[0] + await userEvent.click(addButton) + } +} + +const removeValidatorAccounts = async (accounts: string[]) => { + const validatorAccountsContainer = within(document.getElementsByClassName('validator-accounts')[0] as HTMLElement) + for (const account of accounts) { + const removeButton = validatorAccountsContainer + .getByText(account) + .parentElement?.parentElement?.parentElement?.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, ['alice', 'dave']) + 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: alice', 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: 'alice' })) + + await userEvent.click(getButtonByText(modal, 'Sign and bond')) + }) + + await step('Add Validator Account: dave', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByRole('heading', { name: 'dave' })) + + 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(alice.id) + expect(args.onAddStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(alice.id) + expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, alice.controllerAccount) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, dave.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(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, ['alice', 'dave'])) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + }) + + await step('Add Validator Account: alice', 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: 'alice' })) + + await userEvent.click(getButtonByText(modal, 'Sign and bond')) + }) + + await step('Add Validator Account: dave', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByRole('heading', { name: 'dave' })) + + 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(alice.id) + expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(2) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, alice.controllerAccount) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, dave.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, ['alice'])) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + }) + + await step('Add Validator Account: alice', 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: 'alice' })) + + 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, ['alice', 'dave'])) + await waitFor(() => expect(saveButton).toBeEnabled()) + await userEvent.click(saveButton) + }) + + await step('Add Validator Account: alice', 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: 'alice' })) + + await userEvent.click(getButtonByText(modal, 'Sign and bond')) + }) + + await step('Add Validator Account: dave', async () => { + await waitFor(() => expect(modal.getByText('Authorize transaction'))) + expect(modal.getByRole('heading', { name: 'dave' })) + + 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/Validators/ValidatorList.stories.tsx b/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx index 8ef4d51199..db662556ff 100644 --- a/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx +++ b/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx @@ -370,7 +370,7 @@ 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.getAllByText('alice').length).toEqual(2) expect(screen.queryByText('bob')).toBeNull() await selectFromDropdown(screen, verificationFilter, 'unverified') await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(6)) @@ -394,15 +394,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(7) }) - expect(screen.queryByText('alice')) expect(screen.queryByText('bob')) + expect(screen.queryByText('dave')) }) await step('Clear Filter', async () => { @@ -413,7 +413,7 @@ export const TestsFilters: Story = { await userEvent.click(screen.getByText('Clear all filters')) 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(1)) + 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(7)) 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/modals/BuyMembershipModal/BuyMembershipFormModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx index f8d42a4231..d71ec12173 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx @@ -429,7 +429,7 @@ export const BuyMembershipFormModal = ({ onClose, onSubmit, membershipPrice }: B ) } -const SelectValidatorAccountWrapper = styled.div` +export const SelectValidatorAccountWrapper = styled.div` margin-top: -4px; display: flex; flex-direction: column; diff --git a/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx b/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx index 5279349702..69f38fa2ee 100644 --- a/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx +++ b/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx @@ -1,22 +1,30 @@ -import React, { useCallback, useEffect, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react' import { useForm, FormProvider } from 'react-hook-form' import * as Yup from 'yup' import { AnySchema } from 'yup' -import { filterAccount, SelectAccount } from '@/accounts/components/SelectAccount' +import { filterAccount, SelectAccount, SelectedAccount } from '@/accounts/components/SelectAccount' import { useMyAccounts } from '@/accounts/hooks/useMyAccounts' import { accountOrNamed } from '@/accounts/model/accountOrNamed' -import { InputComponent, InputText, InputTextarea } from '@/common/components/forms' +import { encodeAddress } from '@/accounts/model/encodeAddress' +import { ButtonGhost, ButtonPrimary } from '@/common/components/buttons' +import { InputComponent, InputText, InputTextarea, Label, ToggleCheckbox } from '@/common/components/forms' +import { CrossIcon, PlusIcon } from '@/common/components/icons' import { Loading } from '@/common/components/Loading' import { ModalHeader, ModalTransactionFooter, Row, + RowInline, ScrolledModal, ScrolledModalBody, ScrolledModalContainer, } from '@/common/components/Modal' -import { TextMedium } from '@/common/components/typography' +import { RowGapBlock } from '@/common/components/page/PageContent' +import { Tooltip, TooltipDefault } from '@/common/components/Tooltip' +import { TextMedium, TextSmall } from '@/common/components/typography' +import { Warning } from '@/common/components/Warning' +import { Address } from '@/common/types' import { WithNullableValues } from '@/common/types/form' import { definedValues } from '@/common/utils' import { useYupValidationResolver } from '@/common/utils/validation' @@ -24,16 +32,18 @@ import { AvatarInput } from '@/memberships/components/AvatarInput' import { SocialMediaSelector } from '@/memberships/components/SocialMediaSelector/SocialMediaSelector' import { useUploadAvatarAndSubmit } from '@/memberships/hooks/useUploadAvatarAndSubmit' import { useGetMembersCountQuery } from '@/memberships/queries' +import { useValidators } from '@/validators/hooks/useValidators' import { AvatarURISchema, ExternalResourcesSchema, HandleSchema } from '../../model/validation' import { MemberWithDetails } from '../../types' +import { SelectValidatorAccountWrapper } from '../BuyMembershipModal/BuyMembershipFormModal' import { UpdateMemberForm } from './types' -import { changedOrNull, hasAnyEdits, membershipExternalResourceToObject } from './utils' +import { changedOrNull, hasAnyEdits, hasAnyMetadateChanges, membershipExternalResourceToObject } from './utils' interface Props { onClose: () => void - onSubmit: (params: WithNullableValues) => void + onSubmit: (params: WithNullableValues, memberId: string, controllerAccount: string) => void member: MemberWithDetails } @@ -45,43 +55,85 @@ 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 validators = useValidators() + const validatorAddresses = useMemo( + () => + validators + ?.flatMap(({ stashAccount: stash, controllerAccount: ctrl }) => (ctrl ? [stash, ctrl] : [stash])) + .map(encodeAddress), + [validators] + ) + const isValidatorAccount = useCallback( + (address: Address): boolean | undefined => validatorAddresses?.includes(address), + [validatorAddresses] + ) + const initialValidatorAccounts = useMemo( + () => member.boundAccounts.filter((address) => isValidatorAccount(address)), + [member.boundAccounts, isValidatorAccount] + ) 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) => onSubmit( - changedOrNull( - { ...fields, externalResources: { ...definedValues(fields.externalResources) } }, - getUpdateMemberFormInitial(member) - ) + { + ...changedOrNull( + { ...fields, externalResources: { ...definedValues(fields.externalResources) } }, + updateMemberFormInitial + ), + validatorAccounts: isValidator + ? fields.validatorAccounts?.filter((address) => !initialValidatorAccounts.includes(address)) + : [], + validatorAccountsToBeRemoved: isValidator + ? initialValidatorAccounts.filter((address) => !fields.validatorAccounts?.includes(address)) + : initialValidatorAccounts, + }, + member.id, + member.controllerAccount ) ) + 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, + validatorAccounts: initialValidatorAccounts.length ? [...initialValidatorAccounts] : undefined, + }), + [member, initialValidatorAccounts] + ) + 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', }) - const [controllerAccount, rootAccount, handle] = form.watch(['controllerAccount', 'rootAccount', 'handle']) + 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, isValidator, validatorAccountCandidate, validatorAccounts] = + form.watch([ + 'controllerAccount', + 'rootAccount', + 'handle', + 'isValidator', + 'validatorAccountCandidate', + 'validatorAccounts', + ]) useEffect(() => { form.trigger('handle') @@ -94,7 +146,33 @@ 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 canUpdate = + form.formState.isValid && + hasAnyEdits(form.getValues(), updateMemberFormInitial) && + (!isValidator || validatorAccounts?.length) + + const willBecomeUnverifiedValidator = + updateMemberFormInitial.isValidator && hasAnyMetadateChanges(form.getValues(), updateMemberFormInitial) + + const addValidatorAccount = () => { + if (validatorAccountCandidate) { + setValidatorAccounts([...new Set([...(validatorAccounts ?? []), validatorAccountCandidate.address])]) + form?.setValue('validatorAccountCandidate' as keyof UpdateMemberForm, undefined) + } + } + + const removeValidatorAccount = (index: number) => { + validatorAccounts && + setValidatorAccounts([...validatorAccounts.slice(0, index), ...validatorAccounts.slice(index + 1)]) + } + + const setValidatorAccounts = (accounts: Address[]) => { + form?.setValue('validatorAccounts' as keyof UpdateMemberForm, []) + accounts.map((account, index) => { + form?.register(('validatorAccounts[' + index + ']') as keyof UpdateMemberForm) + form?.setValue(('validatorAccounts[' + index + ']') as keyof UpdateMemberForm, account) + }) + } return ( @@ -153,6 +231,75 @@ export const UpdateMembershipFormModal = ({ onClose, onSubmit, member }: Props) member.externalResources ? member.externalResources.map((resource) => resource.source) : [] } /> + + {willBecomeUnverifiedValidator && ( + + )} + + + + + + + {isValidator && ( + <> + + + + + + + * + + + If your validator account is not in your signer wallet, paste the account address to the field + below: + + + + + + + + + + + + + {validatorAccounts?.map((address, index) => ( + + + + { + removeValidatorAccount(index) + }} + className="remove-button" + > + + + + + ))} + + + )} 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, 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 = [] for (const key of Object.keys(form)) { + if (key === 'validatorCandidate') continue const initialValue = initial[key === 'avatarUri' ? 'avatar' : key] || '' const formValue = form[key]?.address ?? (form[key] || '') if (initialValue !== formValue) { - if (key === 'externalResources') { + if (key === 'externalResources' || key === 'validatorAccounts') { if (JSON.stringify(initialValue) !== JSON.stringify(formValue)) changedFields.push(key) } else { changedFields.push(key) diff --git a/packages/ui/src/mocks/data/raw/members.json b/packages/ui/src/mocks/data/raw/members.json index 5047f572dc..dc1275556a 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": [], @@ -59,7 +60,7 @@ { "id": "2", "rootAccount": "j4UbMHiS79yvMLJctXggUugkkKmwxG5LW2YSy3ap8SmgF5qW9", - "controllerAccount": "j4UbMHiS79yvMLJctXggUugkkKmwxG5LW2YSy3ap8SmgF5qW9", + "controllerAccount": "j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz", "boundAccounts": ["j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz"], "boundAccountsEvents": [], "handle": "charlie", diff --git a/packages/ui/src/validators/hooks/useValidators.ts b/packages/ui/src/validators/hooks/useValidators.ts index 695bc0bc30..9d1dba1590 100644 --- a/packages/ui/src/validators/hooks/useValidators.ts +++ b/packages/ui/src/validators/hooks/useValidators.ts @@ -9,7 +9,7 @@ export const useValidators = ({ skip = false }: Props = {}) => { useEffect(() => { if (!skip) setShouldFetchValidators(true) - }, []) + }, [skip]) return validators } 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' }, - }) - }) - }) -}) From 62ff736d009ea75067f65ed3b7a28fea3e828c13 Mon Sep 17 00:00:00 2001 From: eshark9312 <129978066+eshark9312@users.noreply.github.com> Date: Thu, 18 Jan 2024 00:10:03 -0800 Subject: [PATCH 06/14] Fix endless re-rendering due to the dashboard header (#4738) * fix * fix * fix --- .../app/pages/Validators/ValidatorList.tsx | 4 +- .../validators/components/statistics/Era.tsx | 38 +++++++++++-------- .../validators/hooks/useStakingStatistics.tsx | 33 ++++++++-------- 3 files changed, 41 insertions(+), 34 deletions(-) diff --git a/packages/ui/src/app/pages/Validators/ValidatorList.tsx b/packages/ui/src/app/pages/Validators/ValidatorList.tsx index 0707065435..7c1ae3864f 100644 --- a/packages/ui/src/app/pages/Validators/ValidatorList.tsx +++ b/packages/ui/src/app/pages/Validators/ValidatorList.tsx @@ -18,8 +18,6 @@ import { ValidatorsTabs } from './components/ValidatorsTabs' export const ValidatorList = () => { const { eraStartedOn, - eraDuration, - now, eraRewardPoints, totalRewards, lastRewards, @@ -51,7 +49,7 @@ export const ValidatorList = () => { currentStaking={currentStaking} stakingPercentage={stakingPercentage} /> - + diff --git a/packages/ui/src/validators/components/statistics/Era.tsx b/packages/ui/src/validators/components/statistics/Era.tsx index f27841d7a3..971eaf6eb8 100644 --- a/packages/ui/src/validators/components/statistics/Era.tsx +++ b/packages/ui/src/validators/components/statistics/Era.tsx @@ -1,6 +1,6 @@ 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,25 +12,31 @@ import { formatDurationDate, } from '@/common/components/statistics' import { DurationValue } from '@/common/components/typography/DurationValue' +import { ERA_DURATION } from '@/common/constants' +import { whenDefined } from '@/common/utils' interface EraProps { eraStartedOn: Option | undefined - eraDuration: number - now: u64 | undefined eraRewardPoints: PalletStakingEraRewardPoints | 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]) +export const Era = ({ eraStartedOn, eraRewardPoints }: EraProps) => { + const [spentDuration, setSpentDuration] = useState() + + const { nextReward, percentage } = useMemo( + () => ({ + nextReward: whenDefined(spentDuration, (d) => ERA_DURATION - d), + percentage: spentDuration && Math.ceil((100 * ERA_DURATION) / spentDuration), + }), + [spentDuration] + ) + + useEffect(() => { + if (!eraStartedOn) return + const interval = setInterval(() => setSpentDuration(Math.max(0, Date.now() - Number(eraStartedOn))), 1000) + return () => clearInterval(interval) + }, [eraStartedOn]) + return ( } + actionElement={} > Next Reward
- +
diff --git a/packages/ui/src/validators/hooks/useStakingStatistics.tsx b/packages/ui/src/validators/hooks/useStakingStatistics.tsx index c10e0b45aa..32c7e50ec4 100644 --- a/packages/ui/src/validators/hooks/useStakingStatistics.tsx +++ b/packages/ui/src/validators/hooks/useStakingStatistics.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react' import { combineLatest, map } from 'rxjs' import { useApi } from '@/api/hooks/useApi' -import { ERA_DURATION } from '@/common/constants' +import { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' import { useObservable } from '@/common/hooks/useObservable' export const useStakingStatistics = () => { @@ -19,46 +19,49 @@ export const useStakingStatistics = () => { [api?.isConnected] ) - const now = useObservable(() => api?.query.timestamp.now(), [api?.isConnected]) - const totalIssuance = useObservable(() => api?.query.balances.totalIssuance(), [api?.isConnected]) - const currentStaking = useObservable( + const totalIssuance = useFirstObservableValue(() => api?.query.balances.totalIssuance(), [api?.isConnected]) + const currentStaking = useFirstObservableValue( () => activeEra && api && api.query.staking.erasTotalStake(activeEra.eraIndex), - [activeEra, api?.isConnected] + [api?.isConnected, activeEra] ) - const activeValidators = useObservable(() => api?.query.session.validators(), [api?.isConnected]) - const stakers = useObservable( + const activeValidators = useFirstObservableValue(() => api?.query.session.validators(), [api?.isConnected]) + const stakers = useFirstObservableValue( () => activeValidators && api && activeEra && combineLatest(activeValidators.map((address) => api.query.staking.erasStakers(activeEra.eraIndex, address))), - [api?.isConnected, activeValidators, activeEra] + [api?.isConnected, activeEra, activeValidators] ) 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( + const allValidatorsCount = useFirstObservableValue( + () => api?.query.staking.counterForValidators(), + [api?.isConnected] + ) + const allNominatorsCount = useFirstObservableValue( + () => api?.query.staking.counterForNominators(), + [api?.isConnected] + ) + const lastValidatorRewards = useFirstObservableValue( () => activeEra && api && api.query.staking.erasValidatorReward(activeEra.eraIndex.subn(1)), [activeEra, api?.isConnected] ) - const totalRewards = useObservable(() => api?.derive.staking.erasRewards(), [api?.isConnected]) + const totalRewards = useFirstObservableValue(() => 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] + [api?.isConnected, activeEra] ) return { eraStartedOn: activeEra?.eraStartedOn, - eraDuration: ERA_DURATION, eraRewardPoints, - now, idealStaking: new BN(totalIssuance ?? 0).divn(2), currentStaking: new BN(currentStaking ?? 0), stakingPercentage, From 44d3f797a2b7771e84d2087cb1fe6dbbf7a70728 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Thu, 18 Jan 2024 09:10:36 +0100 Subject: [PATCH 07/14] =?UTF-8?q?=F0=9F=83=8F=20Validators=20sort=20by=20a?= =?UTF-8?q?pr=20(#4739)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Rely on observables laziness to filter and sort validators * Re-implement the status filter * Re-implement sort by APR * Show a loader while sorting validators * Throttle to decrease re-rendering cycles --- .../ui/src/common/model/ObservableList.ts | 38 +++++ .../validators/components/ValidatorsList.tsx | 4 +- .../validators/hooks/useValidatorsList.tsx | 2 +- .../providers/useValidatorsWithDetails.ts | 112 +++++--------- packages/ui/src/validators/providers/utils.ts | 140 +++++++++++------- packages/ui/src/validators/types/Validator.ts | 12 +- 6 files changed, 181 insertions(+), 127 deletions(-) create mode 100644 packages/ui/src/common/model/ObservableList.ts 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/validators/components/ValidatorsList.tsx b/packages/ui/src/validators/components/ValidatorsList.tsx index 9564f2ab98..f24bbccc08 100644 --- a/packages/ui/src/validators/components/ValidatorsList.tsx +++ b/packages/ui/src/validators/components/ValidatorsList.tsx @@ -57,7 +57,7 @@ export const ValidatorsList = ({ validators, order, pagination }: ValidatorsList State Own Stake Total Stake - + Expected Nom APR - + { const [search, setSearch] = useState('') diff --git a/packages/ui/src/validators/providers/useValidatorsWithDetails.ts b/packages/ui/src/validators/providers/useValidatorsWithDetails.ts index 91cd254aa9..1237a5453f 100644 --- a/packages/ui/src/validators/providers/useValidatorsWithDetails.ts +++ b/packages/ui/src/validators/providers/useValidatorsWithDetails.ts @@ -1,16 +1,16 @@ import { useMemo, useState } from 'react' -import { combineLatest, map, Observable, of, ReplaySubject, share, switchMap, take } from 'rxjs' +import { combineLatest, map, merge, Observable, of, scan, switchMap, throttleTime } from 'rxjs' import { useApi } from '@/api/hooks/useApi' import { BN_ZERO } from '@/common/constants' import { useObservable } from '@/common/hooks/useObservable' -import { isDefined } from '@/common/utils' +import { filterObservableList, mapObservableList, sortObservableList } from '@/common/model/ObservableList' import { useGetMembersWithDetailsQuery } from '@/memberships/queries' import { asMemberWithDetails } from '@/memberships/types' -import { Validator, ValidatorDetailsFilter, ValidatorDetailsOrder, ValidatorWithDetails } from '../types' +import { Validator, ValidatorDetailsFilter, ValidatorDetailsOrder, ValidatorInfo, ValidatorWithDetails } from '../types' -import { compareValidators, getValidatorsFilters, getValidatorInfo, filterValidatorsByIsActive } from './utils' +import { getValidatorSortingFns, getValidatorsFilters, getValidatorInfo, keepFirst } from './utils' export type ValidatorDetailsOptions = { filter: ValidatorDetailsFilter @@ -19,11 +19,6 @@ export type ValidatorDetailsOptions = { end: number } -type AggregateResult = { - validators: ValidatorWithDetails[] - size: number -} - export const useValidatorsWithDetails = (allValidatorsWithCtrlAcc: Validator[] | undefined) => { const { api } = useApi() @@ -99,87 +94,62 @@ export const useValidatorsWithDetails = (allValidatorsWithCtrlAcc: Validator[] | .sort((a, b) => b.era - a.era) .slice(1) // Remove the current period }), - keepFirst + keepFirst() ) }, [api?.isConnected, !validatorDetailsOptions]) const activeValidators$ = useMemo(() => { if (!validatorDetailsOptions) return - return api?.query.session.validators().pipe(keepFirst) + return api?.query.session.validators().pipe(keepFirst()) }, [api?.isConnected, !validatorDetailsOptions]) - const aggregated = useObservable(() => { - if (!api || !validatorsWithMembership || !validatorsRewards$ || !activeValidators$ || !validatorDetailsOptions) { - return - } - - if (!validatorsWithMembership.length) return of({ validators: [], size: 0 }) - - const { filter, order, start, end } = validatorDetailsOptions + const validatorsInfo$ = useMemo(() => { + if (!api || !validatorsWithMembership || !validatorsRewards$ || !activeValidators$) return - const filterByState = switchMap( - (validators: ValidatorWithDetails[]): Observable => - isDefined(filter.isActive) - ? activeValidators$.pipe(filterValidatorsByIsActive(validators, filter.isActive)) - : of(validators) + const validatorsInfo = validatorsWithMembership.map((validator) => + getValidatorInfo(validator, activeValidators$, validatorsRewards$, api) ) - const filterSortPaginate = map((validators: ValidatorWithDetails[]): AggregateResult => { - const filtered = getValidatorsFilters(filter).reduce( - (validators: ValidatorWithDetails[], predicate): ValidatorWithDetails[] => - predicate ? validators.filter(predicate) : validators, - validators - ) - - const sortedPaginated = filtered - .sort((a, b) => { - const direction = order.isDescending ? -1 : 1 - return direction * compareValidators(a, b, order.key) - }) - .slice(start, end) - - return { validators: sortedPaginated, size: filtered.length } - }) + return of(validatorsInfo) + }, [api?.isConnected, validatorsWithMembership, validatorsRewards$, activeValidators$]) - const getInfo = switchMap(({ validators, size }: AggregateResult): Observable => { - if (validators.length === 0) return of({ validators: [], size: 0 }) + const [filteredValidatorsInfo$, size$] = useMemo<[Observable, Observable] | []>(() => { + if (!validatorsInfo$ || !validatorDetailsOptions) return [] - const withInfo = combineLatest( - validators.flatMap((validator) => { - const address = validator.stashAccount + const filtered$ = getValidatorsFilters(validatorDetailsOptions.filter).reduce( + (validators$, predicate) => (predicate ? validators$.pipe(filterObservableList(predicate)) : validators$), + validatorsInfo$ + ) - if (!validatorsWithDetailsCache.has(address)) { - const validator$ = getValidatorInfo(validator, activeValidators$, validatorsRewards$, api) - validatorsWithDetailsCache.set(address, validator$) - } + const size$ = filtered$.pipe(map((filtered) => filtered.length)) - return validatorsWithDetailsCache.get(address) as Observable - }) - ) + return [filtered$, size$] + }, [validatorsInfo$, validatorDetailsOptions?.filter]) - return combineLatest({ validators: withInfo, size: of(size) }) - }) + const validatorsWithDetails = useObservable(() => { + if (!filteredValidatorsInfo$ || !size$ || !validatorDetailsOptions) return - return of(validatorsWithMembership).pipe(filterByState, filterSortPaginate, getInfo) - }, [api?.isConnected, validatorsWithMembership, validatorsRewards$, activeValidators$, validatorDetailsOptions]) + 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: aggregated?.validators, - size: aggregated?.size, + validatorsWithDetails, + size: useObservable(() => size$, [size$]), setValidatorDetailsOptions, } } - -const validatorsWithDetailsCache = new Map>() - -const keepFirst = (o: Observable): Observable => - o.pipe( - take(1), - share({ - connector: () => new ReplaySubject(1), - resetOnComplete: false, - resetOnError: false, - resetOnRefCountZero: false, - }) - ) diff --git a/packages/ui/src/validators/providers/utils.ts b/packages/ui/src/validators/providers/utils.ts index e307368f25..b31a662085 100644 --- a/packages/ui/src/validators/providers/utils.ts +++ b/packages/ui/src/validators/providers/utils.ts @@ -1,26 +1,33 @@ import { Vec } from '@polkadot/types' import { AccountId } from '@polkadot/types/interfaces' import BN from 'bn.js' -import { map, merge, Observable, of, ReplaySubject, scan, share, switchMap, take } from 'rxjs' +import { map, Observable, of, OperatorFunction, pipe, ReplaySubject, share, switchMap, take } from 'rxjs' import { Api } from '@/api' import { BN_ZERO, ERAS_PER_YEAR } from '@/common/constants' import { isDefined } from '@/common/utils' -import { ValidatorDetailsFilter, ValidatorDetailsOrder, ValidatorWithDetails } from '../types' +import { ValidatorDetailsFilter, ValidatorDetailsOrder, ValidatorInfo, ValidatorWithDetails } from '../types' -export const getValidatorsFilters = ({ isVerified, search = '' }: ValidatorDetailsFilter) => { +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) && ((v: ValidatorWithDetails) => !!v.isVerifiedValidator === isVerified), + isDefined(isVerified) && (({ validator }: ValidatorInfo) => of(!!validator.isVerifiedValidator === isVerified)), // Search filter s.length > 2 && - (({ membership, stashAccount, controllerAccount }: ValidatorWithDetails) => - isMatch(membership?.handle) || isMatch(stashAccount) || isMatch(controllerAccount)), + (({ validator: { membership, stashAccount, controllerAccount } }: ValidatorInfo) => + of(isMatch(membership?.handle) || isMatch(stashAccount) || isMatch(controllerAccount))), ] } @@ -29,28 +36,39 @@ export const filterValidatorsByIsActive = (validators: ValidatorWithDetails[], i validators.filter(({ stashAccount }) => activeValidators.includes(stashAccount) === isActive) ) -export const compareValidators = ( - a: ValidatorWithDetails, - b: ValidatorWithDetails, +export const getValidatorSortingFns = ( key: ValidatorDetailsOrder['key'] -) => { +): [ + (item: ValidatorInfo) => Observable, + (a: ValidatorWithDetails, b: ValidatorWithDetails) => number +] => { switch (key) { - case 'default': { - 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 '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 a.commission - b.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), + ] } } @@ -66,30 +84,34 @@ export const getValidatorInfo = ( activeValidators$: Observable>, validatorsRewards$: Observable, api: Api -): Observable => { +): ValidatorInfo => { const address = validator.stashAccount - const status$ = activeValidators$.pipe(map((activeValidators) => ({ isActive: activeValidators.includes(address) }))) + const isActive$ = activeValidators$.pipe( + map((activeValidators) => ({ isActive: activeValidators.includes(address) })), + keepFirst() + ) - const rewards$ = validatorsRewards$.pipe( - map((allRewards) => { - const rewards = allRewards.flatMap(({ era, totalPoints, individual, totalPayout }) => { + const rewardHistory$ = validatorsRewards$.pipe( + map((allRewards) => + allRewards.flatMap(({ era, totalPoints, individual, totalPayout }) => { if (!individual[address]) return [] const eraPoints = Number(individual[address]) const eraReward = totalPayout.muln(eraPoints / totalPoints) return { era, eraReward, eraPoints } }) + ) + ) - return { - rewardPointsHistory: rewards.map(({ era, eraPoints }) => ({ era, rewardPoints: eraPoints })), - totalRewards: rewards.reduce((total, { eraReward }) => total.add(eraReward ?? BN_ZERO), BN_ZERO), - latestReward: rewards[0]?.eraReward, - } - }) + 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 stakes$ = api.query.staking.activeEra().pipe( - take(1), + const staking$ = api.query.staking.activeEra().pipe( switchMap((activeEra) => api.query.staking.erasStakers(activeEra.unwrap().index, address)), // TODO handle potential unwrap failure map((stakingInfo) => { const total = stakingInfo.total.toBn() @@ -99,33 +121,47 @@ export const getValidatorInfo = ( })) return { staking: { total, own: stakingInfo.own.toBn(), nominators } } - }) + }), + keepFirst() ) - const slashing$ = api.query.staking.slashingSpans(address).pipe( - take(1), + const apr$ = staking$.pipe( + switchMap(({ staking }) => { + if (staking.total.isZero()) return of({}) + + return rewardHistory$.pipe( + map((rewards) => { + const commission = validator.commission + const latestReward = rewards.at(0)?.eraReward + if (!latestReward) return {} + + const apr = Number(latestReward.muln(ERAS_PER_YEAR).muln(commission).div(staking.total)) + 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 merge(of({}), status$, rewards$, stakes$, slashing$).pipe( - scan((validator: ValidatorWithDetails, part) => ({ ...part, ...validator }), validator), - map((validator) => { - const { commission, staking } = validator - if (!('latestReward' in validator) || !staking || staking.total.isZero()) return validator + return { validator, isActive$, reward$, apr$, staking$, slashed$ } +} - const latestReward = validator.latestReward - const apr = latestReward && Number(latestReward.muln(ERAS_PER_YEAR).muln(commission).div(staking.total)) - return { ...validator, APR: apr } - }), +export const keepFirst = (): OperatorFunction => + pipe( + take(1), share({ connector: () => new ReplaySubject(1), - resetOnError: false, 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 c2e456af90..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' @@ -37,6 +38,15 @@ export interface ValidatorDetailsFilter { } export interface ValidatorDetailsOrder { - key: 'default' | 'commission' + key: 'default' | 'commission' | 'apr' isDescending: boolean } + +export type ValidatorInfo = { + validator: ValidatorWithDetails + isActive$: Observable> + reward$: Observable> + apr$: Observable> + staking$: Observable> + slashed$: Observable> +} From 575782200e663cf5b057d693788221ca84960622 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Mon, 22 Jan 2024 14:42:19 +0100 Subject: [PATCH 08/14] =?UTF-8?q?=F0=9F=A7=B9=20Finalize=20validator=20das?= =?UTF-8?q?hboard=20(#4742)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Factor common queries * Fix tests * Simplify validators mocks * Factor stakers queries * Fix APR and the latest reward stat * Fix uptime stat * Fix the Era statistic * Watch `totalIssuance` and `useObservable` * Move validators mocks to a separate file * Use the reward average to calculate the APR * Apply the "do not show again" box decision * Fix the toggle appearance --- packages/ui/src/app/App.stories.tsx | 67 +--- .../pages/Profile/MyMemberships.stories.tsx | 67 +--- .../Validators/ValidatorList.stories.tsx | 363 +++--------------- .../app/pages/Validators/ValidatorList.tsx | 36 +- .../components/forms/ToggleCheckbox.tsx | 4 +- packages/ui/src/common/constants/numbers.ts | 1 + .../BuyMembershipFormModal.tsx | 12 +- packages/ui/src/mocks/data/raw/members.json | 4 +- packages/ui/src/mocks/data/validators.ts | 76 ++++ packages/ui/src/mocks/helpers/asChainData.ts | 49 ++- packages/ui/src/mocks/providers/api.tsx | 18 +- .../validators/components/ValidatorsList.tsx | 19 +- .../validators/components/statistics/Era.tsx | 21 +- .../components/statistics/ValidatorsState.tsx | 6 +- .../validators/hooks/useStakingStatistics.tsx | 116 +++--- .../validators/hooks/useValidatorsList.tsx | 8 +- .../src/validators/modals/ValidatorsInfo.tsx | 4 +- .../modals/validatorCard/ValidatorCard.tsx | 5 +- .../modals/validatorCard/ValidatorDetail.tsx | 17 +- .../ui/src/validators/providers/provider.tsx | 10 +- .../providers/useValidatorsQueries.ts | 91 +++++ .../providers/useValidatorsWithDetails.ts | 48 +-- packages/ui/src/validators/providers/utils.ts | 48 +-- 23 files changed, 458 insertions(+), 632 deletions(-) create mode 100644 packages/ui/src/mocks/data/validators.ts create mode 100644 packages/ui/src/validators/providers/useValidatorsQueries.ts diff --git a/packages/ui/src/app/App.stories.tsx b/packages/ui/src/app/App.stories.tsx index 81c6587c30..7cf584b7f9 100644 --- a/packages/ui/src/app/App.stories.tsx +++ b/packages/ui/src/app/App.stories.tsx @@ -22,6 +22,7 @@ import { 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' @@ -125,69 +126,13 @@ export default { council: { stage: { stage: { isIdle: true }, changedAt: 123 } }, referendum: { stage: {} }, staking: { - bonded: { - multi: [ - 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', - 'j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW', - 'j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP', - 'j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz', - 'j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa', - 'j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN', - 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', - 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', - 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', - 'j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt', - 'j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM', - ], - }, validators: { - entries: [ - [ - { args: ['5FLSigC9HGRKVhB9FiEo4Y3koPsNmBmLJbpXg2mp1hXcS59Y'] }, - { commission: 0.05 * 10 ** 9, blocked: false }, - ], - [ - { args: ['5DAAnrj7VHTznn2AWBemMuyBwZWs6FNFjdyVXUeYum3PTXFy'] }, - { 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 }, - ], - ], + entries: Object.entries(validators).map(([address, { commission }]) => [ + { args: [address] }, + { commission, blocked: false }, + ]), }, + bonded: { multi: Object.keys(validators) }, }, }, diff --git a/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx b/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx index c38a147894..43b6cdbbf0 100644 --- a/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx +++ b/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx @@ -11,6 +11,7 @@ import { 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' @@ -71,69 +72,13 @@ export default { members: { membershipPrice: joy(20) }, membershipWorkingGroup: { budget: joy(166666_66) }, staking: { - bonded: { - multi: [ - 'j4RLnWh3DWgc9u4CMprqxfBhq3kthXhvZDmnpjEtETFVm446D', - 'j4RbTjvPyaufVVoxVGk5vEKHma1k7j5ZAQCaAL9qMKQWKAswW', - 'j4Rc8VUXGYAx7FNbVZBFU72rQw3GaCuG2AkrUQWnWTh5SpemP', - 'j4Rh1cHtZFAQYGh7Y8RZwoXbkAPtZN46FmuYpKNiR3P2Dc2oz', - 'j4RjraznxDKae1aGL2L2xzXPSf8qCjFbjuw9sPWkoiy1UqWCa', - 'j4RuqkJ2Xqf3NTVRYBUqgbatKVZ31mbK59fWnq4ZzfZvhbhbN', - 'j4RxTMa1QVucodYPfQGA2JrHxZP944dfJ8qdDDYKU4QbJCWNP', - 'j4Rxkb1w9yB6WXroB2npKjRJJxwxbD8JjSQwMZFB31cf5aZAJ', - 'j4RyLBbSUBvipuQLkjLyUGeFWEzmrnfYdpteDa2gYNoM13qEg', - 'j4ShWRXxTG4K5Q5H7KXmdWN8HnaaLwppqM7GdiSwAy3eTLsJt', - 'j4WfB3TD4tFgrJpCmUi8P3wPp3EocyC5At9ZM2YUpmKGJ1FWM', - ], - }, 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 }, - ], - ], + entries: Object.entries(validators).map(([address, { commission }]) => [ + { args: [address] }, + { commission, blocked: false }, + ]), }, + bonded: { multi: Object.keys(validators) }, }, }, derive: { diff --git a/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx b/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx index db662556ff..ba5c1a66d6 100644 --- a/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx +++ b/packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx @@ -1,9 +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 { 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' @@ -11,20 +13,36 @@ import { ValidatorList } from './ValidatorList' type Args = object -const eraRewardEntries = [ - [688, joy(0.123456)], - [689, joy(0.123456)], - [690, joy(0.123456)], - [691, joy(0.123456)], - [692, joy(0.123456)], - [693, joy(0.123456)], - [694, joy(0.123456)], - [695, joy(0.123456)], - [696, joy(0.123456)], - [697, joy(0.123456)], - [698, joy(0.123456)], - [699, joy(0.123456)], -] as const +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', @@ -35,312 +53,55 @@ export default { return { chain: { derive: { - staking: { erasRewards: eraRewardEntries.map(([era, eraReward]) => ({ era, eraReward })) }, + staking: { + 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', - ], + 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', - ], + 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - [ - { 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), - }, - }, - ], - ], - }, - erasValidatorReward: new Map(eraRewardEntries), - 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: 'j4WwTZ3fnkoXJw3D1vGVyymjaiLxM78TGyAAX41JRH8Kx6T2u', value: joy(0.2) }, - { who: 'j4T3XgRMUaZZL6GsMk6RXfBcjuMWxfSLnoATYkBTHh7xyjmoH', value: joy(0.2) }, - { who: 'j4W2bw7ggG69e9TZ77RP9mjem1GrbPwpbKYK7WdZiym77yzMJ', value: joy(0.2) }, - { who: 'j4UzoJUhDGpnsCWrmx9ojofwaT8KHz3azp8C1S49MSN6rYjim', 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 }, - ], - ], - }, }, }, }, + gql: { queries: [ { @@ -371,12 +132,12 @@ export const TestsFilters: Story = { await waitFor(() => expect(screen.queryAllByRole('button', { name: 'Nominate' })).toHaveLength(3)) expect(screen.queryByText('unverifed')).toBeNull() expect(screen.getAllByText('alice').length).toEqual(2) - expect(screen.queryByText('bob')).toBeNull() + expect(screen.queryByText('dave')).toBeNull() await selectFromDropdown(screen, verificationFilter, 'unverified') 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 () => { diff --git a/packages/ui/src/app/pages/Validators/ValidatorList.tsx b/packages/ui/src/app/pages/Validators/ValidatorList.tsx index 7c1ae3864f..1e0ce707e2 100644 --- a/packages/ui/src/app/pages/Validators/ValidatorList.tsx +++ b/packages/ui/src/app/pages/Validators/ValidatorList.tsx @@ -5,6 +5,7 @@ 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' @@ -16,20 +17,20 @@ import { useValidatorsList } from '@/validators/hooks/useValidatorsList' import { ValidatorsTabs } from './components/ValidatorsTabs' export const ValidatorList = () => { + const { validatorsWithDetails, validatorsQueries, allValidatorsCount, format } = useValidatorsList() + const { + eraIndex, eraStartedOn, - eraRewardPoints, totalRewards, lastRewards, idealStaking, - currentStaking, + eraStake, stakingPercentage, activeValidatorsCount, - allValidatorsCount, - acitveNominatorsCount, + activeNominatorsCount, allNominatorsCount, - } = useStakingStatistics() - const { validatorsWithDetails, pagination, order, filter } = useValidatorsList() + } = useStakingStatistics(validatorsQueries) return ( { - + - +
} - main={} + main={ + + } /> ) } diff --git a/packages/ui/src/common/components/forms/ToggleCheckbox.tsx b/packages/ui/src/common/components/forms/ToggleCheckbox.tsx index 954c275680..bd9030e2ea 100644 --- a/packages/ui/src/common/components/forms/ToggleCheckbox.tsx +++ b/packages/ui/src/common/components/forms/ToggleCheckbox.tsx @@ -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/constants/numbers.ts b/packages/ui/src/common/constants/numbers.ts index 814106b0db..5898c14100 100644 --- a/packages/ui/src/common/constants/numbers.ts +++ b/packages/ui/src/common/constants/numbers.ts @@ -9,5 +9,6 @@ export const BN_ZERO = new BN(0) export const SECONDS_PER_BLOCK = 6 export const ERA_DURATION = 21600000 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/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx index d71ec12173..9603539a02 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx @@ -8,7 +8,6 @@ import * as Yup from 'yup' import { SelectAccount, SelectedAccount } from '@/accounts/components/SelectAccount' import { useMyAccounts } from '@/accounts/hooks/useMyAccounts' import { accountOrNamed } from '@/accounts/model/accountOrNamed' -import { encodeAddress } from '@/accounts/model/encodeAddress' import { Account } from '@/accounts/types' import { TermsRoutes } from '@/app/constants/routes' import { ButtonGhost, ButtonPrimary } from '@/common/components/buttons' @@ -154,18 +153,15 @@ export const BuyMembershipForm = ({ 'validatorAccountCandidate', ]) - const validators = useValidators({ skip: isValidator ?? true }) + const validators = useValidators({ skip: !isValidator ?? true }) const [validatorAccounts, setValidatorAccounts] = useState([]) const validatorAddresses = useMemo( - () => - validators - ?.flatMap(({ stashAccount: stash, controllerAccount: ctrl }) => (ctrl ? [stash, ctrl] : [stash])) - .map(encodeAddress), + () => validators?.flatMap(({ stashAccount: stash, controllerAccount: ctrl }) => (ctrl ? [stash, ctrl] : [stash])), [validators] ) const isValidValidatorAccount = useMemo( - () => validatorAccountCandidate && validatorAddresses?.includes(encodeAddress(validatorAccountCandidate.address)), + () => validatorAccountCandidate && validatorAddresses?.includes(validatorAccountCandidate.address), [validatorAccountCandidate, validatorAddresses] ) @@ -302,7 +298,7 @@ export const BuyMembershipForm = ({ !!validatorAddresses?.includes(encodeAddress(account.address))} + filter={(account) => !!validatorAddresses?.includes(account.address)} /> { +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,24 +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 }, - toJSON: { value: () => data }, - get: { - value: (key: any) => { - if (key.toRawType?.() === 'AccountId') { - return data[encodeAddress(key.toString())] - } - return data[key.toString()] - }, - }, - }) +}) diff --git a/packages/ui/src/mocks/providers/api.tsx b/packages/ui/src/mocks/providers/api.tsx index 4021da81c0..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,13 +127,13 @@ 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()) { + switch (typeof value.keys().next().value) { case 'string': return of(asChainData(value.get(String(key)))) case 'number': @@ -156,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/validators/components/ValidatorsList.tsx b/packages/ui/src/validators/components/ValidatorsList.tsx index f24bbccc08..fadbd00164 100644 --- a/packages/ui/src/validators/components/ValidatorsList.tsx +++ b/packages/ui/src/validators/components/ValidatorsList.tsx @@ -20,11 +20,12 @@ import { ValidatorItem } from './ValidatorItem' interface ValidatorsListProps { validators: ValidatorWithDetails[] | undefined + eraIndex: number | undefined order: ValidatorDetailsOrder & { sortBy: (key: ValidatorDetailsOrder['key']) => () => void } pagination: PaginationProps } -export const ValidatorsList = ({ validators, order, pagination }: ValidatorsListProps) => { +export const ValidatorsList = ({ validators, eraIndex, order, pagination }: ValidatorsListProps) => { const { t } = useTranslation('validators') const [cardNumber, selectCard] = useState(null) @@ -64,8 +65,19 @@ export const ValidatorsList = ({ validators, order, pagination }: ValidatorsList

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 + is calculated as follow: +
+ Yearly Reward * 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).
+

} > @@ -96,6 +108,7 @@ export const ValidatorsList = ({ validators, order, pagination }: ValidatorsList diff --git a/packages/ui/src/validators/components/statistics/Era.tsx b/packages/ui/src/validators/components/statistics/Era.tsx index 971eaf6eb8..4babce7c28 100644 --- a/packages/ui/src/validators/components/statistics/Era.tsx +++ b/packages/ui/src/validators/components/statistics/Era.tsx @@ -1,5 +1,3 @@ -import { Option, u64 } from '@polkadot/types' -import { PalletStakingEraRewardPoints } from '@polkadot/types/lookup' import React, { useEffect, useMemo, useState } from 'react' import { PercentageChart } from '@/common/components/charts/PercentageChart' @@ -13,20 +11,23 @@ import { } 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 { whenDefined } from '@/common/utils' interface EraProps { - eraStartedOn: Option | undefined - eraRewardPoints: PalletStakingEraRewardPoints | undefined + eraStartedOn: number | undefined } -export const Era = ({ eraStartedOn, eraRewardPoints }: EraProps) => { +const POINTS_PER_BLOCK = 20 + +export const Era = ({ eraStartedOn }: EraProps) => { const [spentDuration, setSpentDuration] = useState() - const { nextReward, percentage } = useMemo( + const { nextReward, percentage, blocks } = useMemo( () => ({ nextReward: whenDefined(spentDuration, (d) => ERA_DURATION - d), - percentage: spentDuration && Math.ceil((100 * ERA_DURATION) / spentDuration), + percentage: spentDuration && Math.ceil((100 * spentDuration) / ERA_DURATION), + blocks: spentDuration && Math.floor(spentDuration / MILLISECONDS_PER_BLOCK), }), [spentDuration] ) @@ -43,7 +44,6 @@ export const Era = ({ eraStartedOn, eraRewardPoints }: EraProps) => { tooltipText="One era consists of 6 epochs with 1 hour duration each." tooltipTitle="Era" tooltipLinkText="What is an era" - tooltipLinkURL="TBD" actionElement={} > @@ -55,10 +55,9 @@ export const Era = ({ eraStartedOn, eraRewardPoints }: EraProps) => { Blocks / Points
- {eraRewardPoints && ( + {blocks && ( - - {eraRewardPoints.total.toNumber() / 20} / {eraRewardPoints?.total.toNumber()} + {blocks} / {blocks * POINTS_PER_BLOCK} )}
diff --git a/packages/ui/src/validators/components/statistics/ValidatorsState.tsx b/packages/ui/src/validators/components/statistics/ValidatorsState.tsx index 6bf3c35a26..f35be07583 100644 --- a/packages/ui/src/validators/components/statistics/ValidatorsState.tsx +++ b/packages/ui/src/validators/components/statistics/ValidatorsState.tsx @@ -5,14 +5,14 @@ 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 ( @@ -32,7 +32,7 @@ export const ValidatorsState = ({ Nominator (Active / Total) - {acitveNominatorsCount} / {allNominatorsCount} + {activeNominatorsCount} / {allNominatorsCount} diff --git a/packages/ui/src/validators/hooks/useStakingStatistics.tsx b/packages/ui/src/validators/hooks/useStakingStatistics.tsx index 32c7e50ec4..ae7eac08fa 100644 --- a/packages/ui/src/validators/hooks/useStakingStatistics.tsx +++ b/packages/ui/src/validators/hooks/useStakingStatistics.tsx @@ -1,75 +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 { useFirstObservableValue } from '@/common/hooks/useFirstObservableValue' +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 totalIssuance = useFirstObservableValue(() => api?.query.balances.totalIssuance(), [api?.isConnected]) - const currentStaking = useFirstObservableValue( - () => activeEra && api && api.query.staking.erasTotalStake(activeEra.eraIndex), - [api?.isConnected, activeEra] - ) - const activeValidators = useFirstObservableValue(() => api?.query.session.validators(), [api?.isConnected]) - const stakers = useFirstObservableValue( - () => - activeValidators && - api && - activeEra && - combineLatest(activeValidators.map((address) => api.query.staking.erasStakers(activeEra.eraIndex, address))), - [api?.isConnected, activeEra, activeValidators] - ) - 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 = useFirstObservableValue( - () => api?.query.staking.counterForValidators(), - [api?.isConnected] - ) - const allNominatorsCount = useFirstObservableValue( + 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 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( + () => + 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 lastValidatorRewards = useFirstObservableValue( - () => activeEra && api && api.query.staking.erasValidatorReward(activeEra.eraIndex.subn(1)), - [activeEra, api?.isConnected] - ) - const totalRewards = useFirstObservableValue(() => 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), - [api?.isConnected, activeEra] - ) + + const validatorsRewards = useObservable(() => validatorsRewards$, [validatorsRewards$]) + return { - eraStartedOn: activeEra?.eraStartedOn, - eraRewardPoints, - 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/useValidatorsList.tsx b/packages/ui/src/validators/hooks/useValidatorsList.tsx index e600529740..d5d23f79eb 100644 --- a/packages/ui/src/validators/hooks/useValidatorsList.tsx +++ b/packages/ui/src/validators/hooks/useValidatorsList.tsx @@ -21,10 +21,12 @@ export const useValidatorsList = () => { ) const { + validators, setShouldFetchValidators, setValidatorDetailsOptions, validatorsWithDetails, size = 0, + validatorsQueries, } = useContext(ValidatorsContext) const [page, setPage] = useState(1) @@ -47,8 +49,7 @@ export const useValidatorsList = () => { }) }, [filter, order, page]) - return { - validatorsWithDetails, + const format = { pagination, order: { ...order, sortBy: (key: ValidatorDetailsOrder['key']) => () => handleSort(key) }, filter: { @@ -60,4 +61,7 @@ export const useValidatorsList = () => { setIsActive, }, } + const allValidatorsCount = validators?.length + + return { validatorsWithDetails, validatorsQueries, allValidatorsCount, format } } diff --git a/packages/ui/src/validators/modals/ValidatorsInfo.tsx b/packages/ui/src/validators/modals/ValidatorsInfo.tsx index 325a864c05..434d30b4e6 100644 --- a/packages/ui/src/validators/modals/ValidatorsInfo.tsx +++ b/packages/ui/src/validators/modals/ValidatorsInfo.tsx @@ -28,8 +28,8 @@ export const ValidatorsInfo = () => { if (!notShowAgain && showModal) return ( - - + + 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 513e14f432..15fbb8bba6 100644 --- a/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx +++ b/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx @@ -8,7 +8,7 @@ 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' @@ -19,17 +19,20 @@ 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) => - `${((rewardPointsHistory.filter(({ rewardPoints }) => rewardPoints).length / (ERA_DEPTH + 1)) * 100).toFixed(3)}%` - ) + 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 ( <> diff --git a/packages/ui/src/validators/providers/provider.tsx b/packages/ui/src/validators/providers/provider.tsx index f04cf27181..63035a17b1 100644 --- a/packages/ui/src/validators/providers/provider.tsx +++ b/packages/ui/src/validators/providers/provider.tsx @@ -8,6 +8,7 @@ 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 { @@ -20,6 +21,7 @@ export interface UseValidators { validators?: Validator[] validatorsWithDetails?: ValidatorWithDetails[] size?: number + validatorsQueries?: CommonValidatorsQueries } export const ValidatorContextProvider = (props: Props) => { @@ -54,7 +56,12 @@ export const ValidatorContextProvider = (props: Props) => { ) }, [allValidators, api?.isConnected]) - const { validatorsWithDetails, size, setValidatorDetailsOptions } = useValidatorsWithDetails(allValidatorsWithCtrlAcc) + const validatorsQueries = useValidatorsQueries() + + const { validatorsWithDetails, size, setValidatorDetailsOptions } = useValidatorsWithDetails( + allValidatorsWithCtrlAcc, + validatorsQueries + ) const value = { setShouldFetchValidators, @@ -62,6 +69,7 @@ export const ValidatorContextProvider = (props: Props) => { 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 index 1237a5453f..64da55ab15 100644 --- a/packages/ui/src/validators/providers/useValidatorsWithDetails.ts +++ b/packages/ui/src/validators/providers/useValidatorsWithDetails.ts @@ -1,8 +1,7 @@ import { useMemo, useState } from 'react' -import { combineLatest, map, merge, Observable, of, scan, switchMap, throttleTime } from 'rxjs' +import { map, merge, Observable, of, scan, switchMap, throttleTime } from 'rxjs' import { useApi } from '@/api/hooks/useApi' -import { BN_ZERO } from '@/common/constants' import { useObservable } from '@/common/hooks/useObservable' import { filterObservableList, mapObservableList, sortObservableList } from '@/common/model/ObservableList' import { useGetMembersWithDetailsQuery } from '@/memberships/queries' @@ -10,7 +9,8 @@ import { asMemberWithDetails } from '@/memberships/types' import { Validator, ValidatorDetailsFilter, ValidatorDetailsOrder, ValidatorInfo, ValidatorWithDetails } from '../types' -import { getValidatorSortingFns, getValidatorsFilters, getValidatorInfo, keepFirst } from './utils' +import { CommonValidatorsQueries } from './useValidatorsQueries' +import { getValidatorSortingFns, getValidatorsFilters, getValidatorInfo } from './utils' export type ValidatorDetailsOptions = { filter: ValidatorDetailsFilter @@ -19,7 +19,10 @@ export type ValidatorDetailsOptions = { end: number } -export const useValidatorsWithDetails = (allValidatorsWithCtrlAcc: Validator[] | undefined) => { +export const useValidatorsWithDetails = ( + allValidatorsWithCtrlAcc: Validator[] | undefined, + validatorsQueries: CommonValidatorsQueries | undefined +) => { const { api } = useApi() const [validatorDetailsOptions, setValidatorDetailsOptions] = useState() @@ -73,46 +76,15 @@ export const useValidatorsWithDetails = (allValidatorsWithCtrlAcc: Validator[] | }) }, [data, allValidatorsWithCtrlAcc, !validatorDetailsOptions]) - const validatorsRewards$ = useMemo(() => { - if (!api || !validatorDetailsOptions) return - - const eraPoints$ = api.query.staking.erasRewardPoints.entries() - const eraPayouts$ = api.query.staking.erasValidatorReward.entries() - - return combineLatest([eraPoints$, eraPayouts$]).pipe( - map(([points, payouts]) => { - const payoutsMap = new Map(payouts.map(([era, amount]) => [era.args[0].toNumber(), amount.value.toBn()])) - - return points - .map((entry) => { - const era = entry[0].args[0].toNumber() - const totalPoints = entry[1].total.toNumber() - const individual = entry[1].individual.toJSON() as Record - const totalPayout = payoutsMap.get(era) ?? BN_ZERO - return { era, totalPoints, individual, totalPayout } - }) - .sort((a, b) => b.era - a.era) - .slice(1) // Remove the current period - }), - keepFirst() - ) - }, [api?.isConnected, !validatorDetailsOptions]) - - const activeValidators$ = useMemo(() => { - if (!validatorDetailsOptions) return - - return api?.query.session.validators().pipe(keepFirst()) - }, [api?.isConnected, !validatorDetailsOptions]) - const validatorsInfo$ = useMemo(() => { - if (!api || !validatorsWithMembership || !validatorsRewards$ || !activeValidators$) return + if (!api || !validatorsWithMembership || !validatorsQueries) return const validatorsInfo = validatorsWithMembership.map((validator) => - getValidatorInfo(validator, activeValidators$, validatorsRewards$, api) + getValidatorInfo(validator, validatorsQueries, api) ) return of(validatorsInfo) - }, [api?.isConnected, validatorsWithMembership, validatorsRewards$, activeValidators$]) + }, [api?.isConnected, validatorsWithMembership, validatorsQueries]) const [filteredValidatorsInfo$, size$] = useMemo<[Observable, Observable] | []>(() => { if (!validatorsInfo$ || !validatorDetailsOptions) return [] diff --git a/packages/ui/src/validators/providers/utils.ts b/packages/ui/src/validators/providers/utils.ts index b31a662085..59cb67132a 100644 --- a/packages/ui/src/validators/providers/utils.ts +++ b/packages/ui/src/validators/providers/utils.ts @@ -1,7 +1,6 @@ import { Vec } from '@polkadot/types' import { AccountId } from '@polkadot/types/interfaces' -import BN from 'bn.js' -import { map, Observable, of, OperatorFunction, pipe, ReplaySubject, share, switchMap, take } from 'rxjs' +import { map, Observable, of, OperatorFunction, ReplaySubject, share, switchMap } from 'rxjs' import { Api } from '@/api' import { BN_ZERO, ERAS_PER_YEAR } from '@/common/constants' @@ -9,6 +8,8 @@ import { isDefined } from '@/common/utils' import { ValidatorDetailsFilter, ValidatorDetailsOrder, ValidatorInfo, ValidatorWithDetails } from '../types' +import { CommonValidatorsQueries } from './useValidatorsQueries' + export const getValidatorsFilters = ({ isActive, isVerified, @@ -72,17 +73,9 @@ export const getValidatorSortingFns = ( } } -type EraRewards = { - era: number - totalPoints: number - individual: Record - totalPayout: BN -} - export const getValidatorInfo = ( validator: ValidatorWithDetails, - activeValidators$: Observable>, - validatorsRewards$: Observable, + { activeValidators$, stakers$, validatorsRewards$ }: CommonValidatorsQueries, api: Api ): ValidatorInfo => { const address = validator.stashAccount @@ -94,10 +87,10 @@ export const getValidatorInfo = ( const rewardHistory$ = validatorsRewards$.pipe( map((allRewards) => - allRewards.flatMap(({ era, totalPoints, individual, totalPayout }) => { + allRewards.flatMap(({ era, totalPoints, individual, totalReward }) => { if (!individual[address]) return [] const eraPoints = Number(individual[address]) - const eraReward = totalPayout.muln(eraPoints / totalPoints) + const eraReward = totalReward.muln(eraPoints / totalPoints) return { era, eraReward, eraPoints } }) ) @@ -111,9 +104,11 @@ export const getValidatorInfo = ( keepFirst() ) - const staking$ = api.query.staking.activeEra().pipe( - switchMap((activeEra) => api.query.staking.erasStakers(activeEra.unwrap().index, address)), // TODO handle potential unwrap failure + 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(), @@ -131,11 +126,11 @@ export const getValidatorInfo = ( return rewardHistory$.pipe( map((rewards) => { - const commission = validator.commission - const latestReward = rewards.at(0)?.eraReward - if (!latestReward) return {} + if (!rewards.length) return {} - const apr = Number(latestReward.muln(ERAS_PER_YEAR).muln(commission).div(staking.total)) + 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(commission).div(staking.total)) return { APR: apr } }) ) @@ -156,12 +151,9 @@ export const getValidatorInfo = ( } export const keepFirst = (): OperatorFunction => - pipe( - take(1), - share({ - connector: () => new ReplaySubject(1), - resetOnComplete: false, - resetOnError: false, - resetOnRefCountZero: false, - }) - ) + share({ + connector: () => new ReplaySubject(1), + resetOnComplete: false, + resetOnError: false, + resetOnRefCountZero: false, + }) From bab08f6fd5a0e6f9fb5bc84e646772d8f470b153 Mon Sep 17 00:00:00 2001 From: eshark9312 <129978066+eshark9312@users.noreply.github.com> Date: Mon, 22 Jan 2024 07:15:51 -0800 Subject: [PATCH 09/14] Replace spinner with skeleton (#4744) * replace spinner with skeleton * fix --- .../src/common/components/ListItemLoader.tsx | 20 +++--- .../components/ValidatorItemLoading.tsx | 28 +++++++++ .../validators/components/ValidatorsList.tsx | 61 +++++++++++-------- 3 files changed, 75 insertions(+), 34 deletions(-) create mode 100644 packages/ui/src/validators/components/ValidatorItemLoading.tsx 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/validators/components/ValidatorItemLoading.tsx b/packages/ui/src/validators/components/ValidatorItemLoading.tsx new file mode 100644 index 0000000000..bc54295224 --- /dev/null +++ b/packages/ui/src/validators/components/ValidatorItemLoading.tsx @@ -0,0 +1,28 @@ +import React from 'react' + +import { ListItemLoader } from '@/common/components/ListItemLoader' +import { ColumnGapBlock, RowGapBlock } from '@/common/components/page/PageContent' +import { Skeleton } from '@/common/components/Skeleton' + +export const ValidatorItemLoading = React.memo(({ count }: { count: number }) => ( + + + + + + + + + + + + + + + +)) diff --git a/packages/ui/src/validators/components/ValidatorsList.tsx b/packages/ui/src/validators/components/ValidatorsList.tsx index fadbd00164..0f9ef40cda 100644 --- a/packages/ui/src/validators/components/ValidatorsList.tsx +++ b/packages/ui/src/validators/components/ValidatorsList.tsx @@ -6,17 +6,17 @@ 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 { Loading } from '@/common/components/Loading' import { Pagination, PaginationProps } from '@/common/components/Pagination' import { Tooltip, TooltipDefault } from '@/common/components/Tooltip' import { NotFoundText } from '@/common/components/typography/NotFoundText' -import { Colors } from '@/common/constants' +import { BreakPoints, Colors } from '@/common/constants' import { WorkingGroupsRoutes } from '@/working-groups/constants' import { ValidatorCard } from '../modals/validatorCard/ValidatorCard' import { ValidatorDetailsOrder, ValidatorWithDetails } from '../types' import { ValidatorItem } from './ValidatorItem' +import { ValidatorItemLoading } from './ValidatorItemLoading' interface ValidatorsListProps { validators: ValidatorWithDetails[] | undefined @@ -29,9 +29,7 @@ export const ValidatorsList = ({ validators, eraIndex, order, pagination }: Vali const { t } = useTranslation('validators') const [cardNumber, selectCard] = useState(null) - if (!validators) return - - if (!validators.length) return {t('common:forms.noResults')} + if (validators && !validators.length) return {t('common:forms.noResults')} return ( @@ -92,26 +90,32 @@ export const ValidatorsList = ({ validators, eraIndex, order, pagination }: Vali Commission - - {validators?.map((validator, index) => ( - { - selectCard(index + 1) - }} - > - - - ))} - - {cardNumber && validators[cardNumber - 1] && ( - + {!validators ? ( + + ) : ( + <> + + {validators?.map((validator, index) => ( + { + selectCard(index + 1) + }} + > + + + ))} + + {cardNumber && validators[cardNumber - 1] && ( + + )} + )} @@ -131,9 +135,12 @@ const ResponsiveWrap = styled.div` overflow: auto; align-self: stretch; max-width: calc(100vw - 32px); - @media (min-width: 768px) { + @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` @@ -144,7 +151,7 @@ const ValidatorsListWrap = styled.div` 'validatorstablenav' 'validatorslist'; grid-row-gap: 4px; - min-width: 977px; + min-width: 1166px; ${List} { gap: 8px; From 6c1ef8d8c829099d1192f28a41e88ddb220ddab7 Mon Sep 17 00:00:00 2001 From: Theophile Sandoz Date: Tue, 30 Jan 2024 09:59:25 +0100 Subject: [PATCH 10/14] =?UTF-8?q?=F0=9F=94=8D=20Check=20transaction=20sign?= =?UTF-8?q?er=20addresses=20in=20interactions=20tests=20(#4704)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Pass the signer address to mock tx in interaction tests * Fix existing tests --- packages/ui/src/app/App.stories.tsx | 4 +- .../Proposals/CurrentProposals.stories.tsx | 56 ++++++++++--------- .../Proposals/ProposalPreview.stories.tsx | 32 +++++++++-- .../WorkingGroup/WorkingGroup.stories.tsx | 8 ++- packages/ui/src/mocks/data/proposals.ts | 4 +- packages/ui/src/mocks/helpers/transactions.ts | 4 +- 6 files changed, 71 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/app/App.stories.tsx b/packages/ui/src/app/App.stories.tsx index 7cf584b7f9..8431149645 100644 --- a/packages/ui/src/app/App.stories.tsx +++ b/packages/ui/src/app/App.stories.tsx @@ -463,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, @@ -508,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, diff --git a/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx b/packages/ui/src/app/pages/Proposals/CurrentProposals.stories.tsx index cfbea135a7..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,7 +781,7 @@ 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: alice.controllerAccount, amount: 100_0000000000 }], }) @@ -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 e8244641a6..0534128e7c 100644 --- a/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx +++ b/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx @@ -468,7 +468,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')) }) @@ -502,7 +508,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')) }) @@ -538,7 +550,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')) }) @@ -566,7 +584,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' + ) }) }) }, 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 226e4c77be..b302ee68ff 100644 --- a/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.stories.tsx +++ b/packages/ui/src/app/pages/WorkingGroups/WorkingGroup/WorkingGroup.stories.tsx @@ -212,7 +212,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, @@ -276,7 +278,9 @@ export const CreateOpeningImport: Story = { await userEvent.click(modal.getByText('Sign transaction and Create')) }) 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/mocks/data/proposals.ts b/packages/ui/src/mocks/data/proposals.ts index af545621a3..7f2b6c261e 100644 --- a/packages/ui/src/mocks/data/proposals.ts +++ b/packages/ui/src/mocks/data/proposals.ts @@ -287,8 +287,8 @@ export const proposalsPagesChain = ( utility: { batch: { failure: createProposalFailure, - onSend: (transactions: SubmittableExtrinsic<'rxjs'>[]) => - transactions.forEach((transaction) => transaction.signAndSend('')), + onSend: (signer: string, transactions: SubmittableExtrinsic<'rxjs'>[]) => + transactions.forEach((transaction) => transaction.signAndSend(signer)), }, }, }, 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 }, } From 10f1ce6f324c12cdd1bdcc4a1dc7ee77008e430a Mon Sep 17 00:00:00 2001 From: eshark9312 <129978066+eshark9312@users.noreply.github.com> Date: Wed, 31 Jan 2024 11:26:58 -0800 Subject: [PATCH 11/14] Address Validator dashboard QA issues (#4753) * fix validatorlist filter - input notification & enter on empty search box * don't show validation message until enter key typed * show 'All' in the filter select as default * fix validtor card responsiveness * fix typo 'Norminate' * rename reward widget label - last era * fix validator page widgets tooltips * fix social media icons as hyperlink * Rename `ExternalResourceLink` and type it * add 2 decimals to apr * fix search box not to show invalid choice until type enter * Update packages/ui/src/memberships/constants/externalResourceLink.ts Co-authored-by: Theophile Sandoz * fix * fix apr calculation * fix * add commission tooltip * Update packages/ui/src/validators/hooks/useValidatorsList.tsx Co-authored-by: Theophile Sandoz --------- Co-authored-by: Theophile Sandoz --- .../forms/FilterBox/FilterSearchBox.tsx | 17 +++++++---- packages/ui/src/common/utils/object.ts | 3 ++ .../MemberProfile/MemberDetails.tsx | 15 +++------- .../constants/externalResourceLink.ts | 8 ++++++ .../ui/src/memberships/constants/index.ts | 1 + .../validators/components/ValidatorInfo.tsx | 10 ++++--- .../components/ValidatorsFilter.tsx | 28 +++++++++++++------ .../validators/components/ValidatorsList.tsx | 5 +++- .../validators/components/statistics/Era.tsx | 20 ++++++------- .../components/statistics/Rewards.tsx | 2 +- .../components/statistics/Staking.tsx | 8 +++--- .../components/statistics/ValidatorsState.tsx | 16 +++++------ .../validators/hooks/useValidatorsList.tsx | 2 +- .../NominatingRedirectModal.tsx | 2 +- .../modals/validatorCard/Nominators.tsx | 12 +++++--- .../modals/validatorCard/ValidatorDetail.tsx | 19 +++++++++++-- packages/ui/src/validators/providers/utils.ts | 9 +++++- 17 files changed, 115 insertions(+), 62 deletions(-) create mode 100644 packages/ui/src/memberships/constants/externalResourceLink.ts diff --git a/packages/ui/src/common/components/forms/FilterBox/FilterSearchBox.tsx b/packages/ui/src/common/components/forms/FilterBox/FilterSearchBox.tsx index a9b20766ea..8191fa5bd0 100644 --- a/packages/ui/src/common/components/forms/FilterBox/FilterSearchBox.tsx +++ b/packages/ui/src/common/components/forms/FilterBox/FilterSearchBox.tsx @@ -41,9 +41,16 @@ interface SearchBoxProps extends ControlProps { } export const SearchBox = React.memo(({ value, onApply, onChange, label, displayReset }: SearchBoxProps) => { const change = onChange && (({ target }: ChangeEvent) => onChange(target.value)) - const isValid = () => !value || value.length === 0 || value.length > 2 - const keyDown = - !isValid() || !value || !onApply ? undefined : ({ key }: React.KeyboardEvent) => key === 'Enter' && onApply() + const isValid = !value || value.length === 0 || value.length > 2 + 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 && onApply && @@ -56,8 +63,8 @@ export const SearchBox = React.memo(({ value, onApply, onChange, label, displayR {label} {displayReset && value && ( diff --git a/packages/ui/src/common/utils/object.ts b/packages/ui/src/common/utils/object.ts index 65b3fce5d8..4e9f1a086b 100644 --- a/packages/ui/src/common/utils/object.ts +++ b/packages/ui/src/common/utils/object.ts @@ -22,3 +22,6 @@ const mapEntries = ( transform: (x: any, key?: string | number, context?: AnyObject) => 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/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/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/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/ValidatorsFilter.tsx b/packages/ui/src/validators/components/ValidatorsFilter.tsx index 5772a80b38..7b046d63ec 100644 --- a/packages/ui/src/validators/components/ValidatorsFilter.tsx +++ b/packages/ui/src/validators/components/ValidatorsFilter.tsx @@ -1,7 +1,8 @@ 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' @@ -24,8 +25,8 @@ export const ValidatorsFilter = ({ filter }: ValidatorFilterProps) => { const display = () => filter.setSearch(search) const { isVerified, isActive } = filter - const verificationValue = isVerified === true ? 'verified' : isVerified === false ? 'unverified' : undefined - const stateValue = isActive === true ? 'active' : isActive === false ? 'waiting' : undefined + const verificationValue = isVerified === true ? 'verified' : isVerified === false ? 'unverified' : null + const stateValue = isActive === true ? 'active' : isActive === false ? 'waiting' : null const clear = filter.search || verificationValue || stateValue @@ -37,8 +38,8 @@ export const ValidatorsFilter = ({ filter }: ValidatorFilterProps) => { : undefined return ( - - + + { /> - - + + ) } +const ValidatorFilterBox = styled(FilterBox)` + ${Fields} { + padding-bottom: 22px; + } +` + const SelectFields = styled.div` display: flex; justify-content: flex-start; @@ -74,11 +81,16 @@ const SelectFields = styled.div` } } ` -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 0f9ef40cda..a45eeaa3b4 100644 --- a/packages/ui/src/validators/components/ValidatorsList.tsx +++ b/packages/ui/src/validators/components/ValidatorsList.tsx @@ -65,7 +65,7 @@ export const ValidatorsList = ({ validators, eraIndex, order, pagination }: Vali The APR is subject to the amount staked and have a diminishing return for higher token amounts. This is calculated as follow:
- Yearly Reward * Commission / Stake + Yearly Reward * (1 - Commission) / Stake
Reward:
Average reward generated (during the last 30 days) extrapolated over a year.
@@ -88,6 +88,9 @@ export const ValidatorsList = ({ validators, eraIndex, order, pagination }: Vali isDescending={order.isDescending} > Commission + The validator commission on the nominators rewards

}> + +
{!validators ? ( diff --git a/packages/ui/src/validators/components/statistics/Era.tsx b/packages/ui/src/validators/components/statistics/Era.tsx index 4babce7c28..10f52d7304 100644 --- a/packages/ui/src/validators/components/statistics/Era.tsx +++ b/packages/ui/src/validators/components/statistics/Era.tsx @@ -12,7 +12,7 @@ import { import { DurationValue } from '@/common/components/typography/DurationValue' import { ERA_DURATION } from '@/common/constants' import { MILLISECONDS_PER_BLOCK } from '@/common/model/formatters' -import { whenDefined } from '@/common/utils' +import { isDefined, whenDefined } from '@/common/utils' interface EraProps { eraStartedOn: number | undefined @@ -44,23 +44,23 @@ export const Era = ({ eraStartedOn }: EraProps) => { tooltipText="One era consists of 6 epochs with 1 hour duration each." tooltipTitle="Era" tooltipLinkText="What is an era" - actionElement={} + actionElement={isDefined(percentage) && } > Next Reward -
- -
+
{isDefined(nextReward) && }
Blocks / Points -
- {blocks && ( - + + {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 f35be07583..2abc3e5d90 100644 --- a/packages/ui/src/validators/components/statistics/ValidatorsState.tsx +++ b/packages/ui/src/validators/components/statistics/ValidatorsState.tsx @@ -16,23 +16,21 @@ export const ValidatorsState = ({ allNominatorsCount, }: ValidatorsStateProps) => { return ( - + Validator (Active / Waiting) - {activeValidatorsCount} / {allValidatorsCount - activeValidatorsCount} + {`${activeValidatorsCount > 0 ? activeValidatorsCount : '-'} / ${ + allValidatorsCount && activeValidatorsCount ? allValidatorsCount - activeValidatorsCount : '-' + }`} Nominator (Active / Total) - {activeNominatorsCount} / {allNominatorsCount} + {`${activeNominatorsCount > 0 ? activeValidatorsCount : '-'} / ${ + activeNominatorsCount && allNominatorsCount ? allNominatorsCount : '-' + }`} diff --git a/packages/ui/src/validators/hooks/useValidatorsList.tsx b/packages/ui/src/validators/hooks/useValidatorsList.tsx index d5d23f79eb..8a5ae7b717 100644 --- a/packages/ui/src/validators/hooks/useValidatorsList.tsx +++ b/packages/ui/src/validators/hooks/useValidatorsList.tsx @@ -4,7 +4,7 @@ import { ValidatorsContext } from '../providers/context' import { ValidatorDetailsOrder } from '../types' const VALIDATOR_PER_PAGE = 7 -const DESCENDING_KEYS: ValidatorDetailsOrder['key'][] = ['apr', 'commission'] +const DESCENDING_KEYS: ValidatorDetailsOrder['key'][] = ['apr'] export const useValidatorsList = () => { const [search, setSearch] = useState('') 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/validatorCard/Nominators.tsx b/packages/ui/src/validators/modals/validatorCard/Nominators.tsx index d7c9df0f6d..acaa3689fe 100644 --- a/packages/ui/src/validators/modals/validatorCard/Nominators.tsx +++ b/packages/ui/src/validators/modals/validatorCard/Nominators.tsx @@ -29,10 +29,10 @@ export const Nominators = ({ validator }: Props) => { {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/ValidatorDetail.tsx b/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx index 15fbb8bba6..e46b788ce1 100644 --- a/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx +++ b/packages/ui/src/validators/modals/validatorCard/ValidatorDetail.tsx @@ -68,7 +68,9 @@ export const ValidatorDetail = ({ validator, eraIndex, hideModal }: Props) => {
Era points
- +
+ +
)} @@ -115,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)` @@ -123,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/utils.ts b/packages/ui/src/validators/providers/utils.ts index 59cb67132a..00c4349b5f 100644 --- a/packages/ui/src/validators/providers/utils.ts +++ b/packages/ui/src/validators/providers/utils.ts @@ -130,7 +130,14 @@ export const getValidatorInfo = ( 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(commission).div(staking.total)) + const apr = + Number( + averageReward + .muln(ERAS_PER_YEAR) + .muln(100 - commission) + .muln(100) + .div(staking.total) + ) / 100 return { APR: apr } }) ) From ae3cb33e81ba3f8f088d936b96f10df8d6db1af7 Mon Sep 17 00:00:00 2001 From: eshark9312 <129978066+eshark9312@users.noreply.github.com> Date: Thu, 1 Feb 2024 09:16:37 -0800 Subject: [PATCH 12/14] Fix nominator count in the validator dashboard (#4755) * fix * Fix failing tests --------- Co-authored-by: Theophile Sandoz --- packages/ui/src/app/App.stories.tsx | 31 +++++++++----- .../pages/Profile/MyMemberships.stories.tsx | 40 ++++++++++++++----- .../components/statistics/ValidatorsState.tsx | 2 +- 3 files changed, 53 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/app/App.stories.tsx b/packages/ui/src/app/App.stories.tsx index 8431149645..2313d38e9e 100644 --- a/packages/ui/src/app/App.stories.tsx +++ b/packages/ui/src/app/App.stories.tsx @@ -166,8 +166,8 @@ export default { utility: { batch: { event: 'TxBatch', - onSend: (transactions: SubmittableExtrinsic<'rxjs'>[]) => - transactions.forEach((transaction) => transaction.signAndSend('')), + onSend: (signer: string, transactions: SubmittableExtrinsic<'rxjs'>[]) => + transactions.forEach((transaction) => transaction.signAndSend(signer)), failure: parameters.batchTxFailure, }, }, @@ -637,7 +637,7 @@ export const BuyMembershipHappyBindOneValidatorHappy: Story = { await step('Confirm', async () => { 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, @@ -650,9 +650,13 @@ export const BuyMembershipHappyBindOneValidatorHappy: Story = { referrerId: undefined, }) expect(args.onAddStakingAccount).toHaveBeenCalledTimes(1) - expect(args.onAddStakingAccount).toHaveBeenCalledWith(NEW_MEMBER_DATA.id) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(charlie.controllerAccount, NEW_MEMBER_DATA.id) expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(1) - expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(NEW_MEMBER_DATA.id, charlie.controllerAccount) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + bob.controllerAccount, + NEW_MEMBER_DATA.id, + charlie.controllerAccount + ) const doneButton = getButtonByText(modal, 'Done') expect(doneButton).toBeEnabled() @@ -722,7 +726,7 @@ export const BuyMembershipHappyAddTwoValidatorHappy: Story = { await step('Confirm', async () => { 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, @@ -735,10 +739,19 @@ export const BuyMembershipHappyAddTwoValidatorHappy: Story = { referrerId: undefined, }) expect(args.onAddStakingAccount).toHaveBeenCalledTimes(2) - expect(args.onAddStakingAccount).toHaveBeenCalledWith(NEW_MEMBER_DATA.id) + 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(NEW_MEMBER_DATA.id, charlie.controllerAccount) - expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(NEW_MEMBER_DATA.id, dave.controllerAccount) + 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() diff --git a/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx b/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx index 43b6cdbbf0..7daf09389f 100644 --- a/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx +++ b/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx @@ -137,8 +137,8 @@ export default { utility: { batch: { event: 'TxBatch', - onSend: (transactions: SubmittableExtrinsic<'rxjs'>[]) => - transactions.forEach((transaction) => transaction.signAndSend('')), + onSend: (signer: string, transactions: SubmittableExtrinsic<'rxjs'>[]) => + transactions.forEach((transaction) => transaction.signAndSend(signer)), failure: parameters.batchTxFailure, }, }, @@ -355,12 +355,22 @@ export const UpdateValidatorAccountsHappy: Story = { expect(await modal.findByText('Success')) expect(modal.getByText('alice')) expect(args.onRemoveStakingAccount).toHaveBeenCalledTimes(2) - expect(args.onRemoveStakingAccount).toBeCalledWith(alice.id) + 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(alice.id) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(alice.controllerAccount, alice.id) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(dave.controllerAccount, alice.id) expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(2) - expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, alice.controllerAccount) - expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, dave.controllerAccount) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + alice.controllerAccount, + alice.id, + alice.controllerAccount + ) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + alice.controllerAccount, + alice.id, + dave.controllerAccount + ) }) }, } @@ -404,7 +414,8 @@ export const UnbondValidatorAccountsHappy: Story = { expect(await modal.findByText('Success')) expect(modal.getByText('alice')) expect(args.onRemoveStakingAccount).toHaveBeenCalledTimes(2) - expect(args.onRemoveStakingAccount).toBeCalledWith(alice.id) + expect(args.onRemoveStakingAccount).toBeCalledWith(bob.controllerAccount, alice.id) + expect(args.onRemoveStakingAccount).toBeCalledWith(charlie.controllerAccount, alice.id) }) }, } @@ -485,10 +496,19 @@ export const BondValidatorAccountsHappy: Story = { expect(await modal.findByText('Success')) expect(modal.getByText('alice')) expect(args.onAddStakingAccount).toHaveBeenCalledTimes(2) - expect(args.onAddStakingAccount).toHaveBeenCalledWith(alice.id) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(alice.controllerAccount, alice.id) + expect(args.onAddStakingAccount).toHaveBeenCalledWith(dave.controllerAccount, alice.id) expect(args.onConfirmStakingAccount).toHaveBeenCalledTimes(2) - expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, alice.controllerAccount) - expect(args.onConfirmStakingAccount).toHaveBeenCalledWith(alice.id, dave.controllerAccount) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + alice.controllerAccount, + alice.id, + alice.controllerAccount + ) + expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( + alice.controllerAccount, + alice.id, + dave.controllerAccount + ) }) }, } diff --git a/packages/ui/src/validators/components/statistics/ValidatorsState.tsx b/packages/ui/src/validators/components/statistics/ValidatorsState.tsx index 2abc3e5d90..351a7e00bd 100644 --- a/packages/ui/src/validators/components/statistics/ValidatorsState.tsx +++ b/packages/ui/src/validators/components/statistics/ValidatorsState.tsx @@ -28,7 +28,7 @@ export const ValidatorsState = ({ Nominator (Active / Total) - {`${activeNominatorsCount > 0 ? activeValidatorsCount : '-'} / ${ + {`${activeNominatorsCount > 0 ? activeNominatorsCount : '-'} / ${ activeNominatorsCount && allNominatorsCount ? allNominatorsCount : '-' }`} From 703fc4232f9e0db12ee1e316e9036efd3fd191fd Mon Sep 17 00:00:00 2001 From: eshark9312 <129978066+eshark9312@users.noreply.github.com> Date: Tue, 13 Feb 2024 01:49:43 -0800 Subject: [PATCH 13/14] Address create/update validator membership QA issues (#4767) * fix typo, warning layout * fix validator account select UX * fix storybook * fix invalid validator account input * fix * fix update validator membership UX * Factor validator selection * initialize the updateMembershipForm with the member's current details * fix create/update membership interaction test * fix invalid validator account input - interact test --------- Co-authored-by: Theophile Sandoz --- packages/ui/src/app/App.stories.tsx | 19 +- .../pages/Profile/MyMemberships.stories.tsx | 76 ++++--- .../ui/src/common/components/Modal/Modals.tsx | 3 +- packages/ui/src/common/components/Warning.tsx | 4 +- .../components/forms/ToggleCheckbox.tsx | 2 +- packages/ui/src/common/model/Polyfill.ts | 11 + .../components/SelectValidatorAccounts.tsx | 188 +++++++++++++++++ .../BuyMembershipFormModal.tsx | 173 +++------------ .../BuyMembershipSignModal.tsx | 7 +- .../modals/BuyMembershipModal/machine.ts | 4 +- .../UpdateMembershipFormModal.tsx | 198 ++++++------------ .../modals/UpdateMembershipModal/types.ts | 2 - .../modals/UpdateMembershipModal/utils.ts | 1 - packages/ui/src/mocks/data/validators.ts | 2 +- 14 files changed, 356 insertions(+), 334 deletions(-) create mode 100644 packages/ui/src/common/model/Polyfill.ts create mode 100644 packages/ui/src/memberships/components/SelectValidatorAccounts.tsx diff --git a/packages/ui/src/app/App.stories.tsx b/packages/ui/src/app/App.stories.tsx index 2313d38e9e..ce62a5d97a 100644 --- a/packages/ui/src/app/App.stories.tsx +++ b/packages/ui/src/app/App.stories.tsx @@ -577,11 +577,15 @@ const fillMembershipFormValidatorAccounts = async (modal: Container, accounts: s const validatorCheckButton = modal.getAllByText('Yes')[1] await userEvent.click(validatorCheckButton) expect(await modal.findByText(/^If your validator account/)) - for (const account of accounts) { - await selectFromDropdown(modal, /^If your validator account/, account) - const addButton = document.getElementsByClassName('add-button')[0] + 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 = { @@ -772,13 +776,16 @@ export const InvalidValidatorAccountInput: Story = { await fillMembershipForm(modal) const validatorCheckButton = modal.getAllByText('Yes')[1] await userEvent.click(validatorCheckButton) - const validatorAddressInputElement = document.getElementById('select-validatorAccount-input') + 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 addButton = document.getElementsByClassName('add-button')[0] - expect(addButton).toBeDisabled() + const createButton = getButtonByText(modal, 'Create a Membership') + expect(createButton).toBeDisabled() }, } diff --git a/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx b/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx index 7daf09389f..b04221ce88 100644 --- a/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx +++ b/packages/ui/src/app/pages/Profile/MyMemberships.stories.tsx @@ -21,6 +21,7 @@ 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, @@ -64,7 +65,7 @@ export default { return { accounts: { active: 'alice', - list: [account(alice), account(bob), account(charlie), account(dave)], + list: [account(alice), account(bob), account(charlie), account(dave), account(eve)], hasWallet: true, }, chain: { @@ -157,7 +158,7 @@ export default { }, { query: GetMembersWithDetailsDocument, - data: { memberships: [member('alice'), member('bob'), member('charlie'), member('dave')] }, + data: { memberships: [member('alice'), member('bob'), member('charlie'), member('dave'), member('eve')] }, }, { query: GetMemberActionDetailsDocument, @@ -265,19 +266,30 @@ export const UpdateMembershipFailure: Story = { } const addValidatorAccounts = async (modal: Container, accounts: string[]) => { - for (const account of accounts) { - await selectFromDropdown(modal, /^If your validator account/, account) - const addButton = document.getElementsByClassName('add-button')[0] + 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 = validatorAccountsContainer - .getByText(account) - .parentElement?.parentElement?.parentElement?.querySelector('.remove-button') + const removeButton = nthParentElement(validatorAccountsContainer.getByText(account), 8)?.querySelector( + '.remove-button' + ) if (!removeButton) throw `Not found the '${account}' account to removed.` await userEvent.click(removeButton) } @@ -297,7 +309,7 @@ export const UpdateValidatorAccountsHappy: Story = { expect(saveButton).toBeDisabled() await fillMembershipForm(modal) await removeValidatorAccounts(['bob', 'charlie']) - await addValidatorAccounts(modal, ['alice', 'dave']) + await addValidatorAccounts(modal, ['dave', 'eve']) await waitFor(() => expect(saveButton).toBeEnabled()) await userEvent.click(saveButton) }) @@ -318,18 +330,18 @@ export const UpdateValidatorAccountsHappy: Story = { await userEvent.click(getButtonByText(modal, 'Sign and unbond')) }) - await step('Add Validator Account: alice', async () => { + 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: 'alice' })) + expect(modal.getByRole('heading', { name: 'dave' })) await userEvent.click(getButtonByText(modal, 'Sign and bond')) }) - await step('Add Validator Account: dave', async () => { + await step('Add Validator Account: eve', async () => { await waitFor(() => expect(modal.getByText('Authorize transaction'))) - expect(modal.getByRole('heading', { name: 'dave' })) + expect(modal.getByRole('heading', { name: 'eve' })) await userEvent.click(getButtonByText(modal, 'Sign and bond')) }) @@ -358,18 +370,18 @@ export const UpdateValidatorAccountsHappy: Story = { 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(alice.controllerAccount, alice.id) 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, - alice.controllerAccount + dave.controllerAccount ) expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( alice.controllerAccount, alice.id, - dave.controllerAccount + eve.controllerAccount ) }) }, @@ -463,23 +475,23 @@ export const BondValidatorAccountsHappy: Story = { await step('Form', async () => { const saveButton = getButtonByText(modal, 'Save changes') expect(saveButton).toBeDisabled() - await waitFor(() => addValidatorAccounts(modal, ['alice', 'dave'])) + await waitFor(() => addValidatorAccounts(modal, ['dave', 'eve'])) await waitFor(() => expect(saveButton).toBeEnabled()) await userEvent.click(saveButton) }) - await step('Add Validator Account: alice', async () => { + 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: 'alice' })) + expect(modal.getByRole('heading', { name: 'dave' })) await userEvent.click(getButtonByText(modal, 'Sign and bond')) }) - await step('Add Validator Account: dave', async () => { + await step('Add Validator Account: eve', async () => { await waitFor(() => expect(modal.getByText('Authorize transaction'))) - expect(modal.getByRole('heading', { name: 'dave' })) + expect(modal.getByRole('heading', { name: 'eve' })) await userEvent.click(getButtonByText(modal, 'Sign and bond')) }) @@ -496,18 +508,18 @@ export const BondValidatorAccountsHappy: Story = { expect(await modal.findByText('Success')) expect(modal.getByText('alice')) expect(args.onAddStakingAccount).toHaveBeenCalledTimes(2) - expect(args.onAddStakingAccount).toHaveBeenCalledWith(alice.controllerAccount, alice.id) 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, - alice.controllerAccount + dave.controllerAccount ) expect(args.onConfirmStakingAccount).toHaveBeenCalledWith( alice.controllerAccount, alice.id, - dave.controllerAccount + eve.controllerAccount ) }) }, @@ -526,16 +538,16 @@ export const BondValidatorAccountFailure: Story = { await step('Form', async () => { const saveButton = getButtonByText(modal, 'Save changes') expect(saveButton).toBeDisabled() - await waitFor(() => addValidatorAccounts(modal, ['alice'])) + await waitFor(() => addValidatorAccounts(modal, ['dave'])) await waitFor(() => expect(saveButton).toBeEnabled()) await userEvent.click(saveButton) }) - await step('Add Validator Account: alice', async () => { + 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: 'alice' })) + expect(modal.getByRole('heading', { name: 'dave' })) await userEvent.click(getButtonByText(modal, 'Sign and bond')) expect(await modal.findByText('Failure')) @@ -557,23 +569,23 @@ export const UnbondValidatorAccountHappyConfirmFailure: Story = { await step('Form', async () => { const saveButton = getButtonByText(modal, 'Save changes') expect(saveButton).toBeDisabled() - await waitFor(() => addValidatorAccounts(modal, ['alice', 'dave'])) + await waitFor(() => addValidatorAccounts(modal, ['dave', 'eve'])) await waitFor(() => expect(saveButton).toBeEnabled()) await userEvent.click(saveButton) }) - await step('Add Validator Account: alice', async () => { + 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: 'alice' })) + expect(modal.getByRole('heading', { name: 'dave' })) await userEvent.click(getButtonByText(modal, 'Sign and bond')) }) - await step('Add Validator Account: dave', async () => { + await step('Add Validator Account: eve', async () => { await waitFor(() => expect(modal.getByText('Authorize transaction'))) - expect(modal.getByRole('heading', { name: 'dave' })) + expect(modal.getByRole('heading', { name: 'eve' })) await userEvent.click(getButtonByText(modal, 'Sign and bond')) }) diff --git a/packages/ui/src/common/components/Modal/Modals.tsx b/packages/ui/src/common/components/Modal/Modals.tsx index 8fb3bb58c1..5752ab32c5 100644 --- a/packages/ui/src/common/components/Modal/Modals.tsx +++ b/packages/ui/src/common/components/Modal/Modals.tsx @@ -17,12 +17,13 @@ export const Row = styled.div` height: auto; ` -export const RowInline = styled.div<{ gap?: number; top?: number }>` +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; ` 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/ToggleCheckbox.tsx b/packages/ui/src/common/components/forms/ToggleCheckbox.tsx index bd9030e2ea..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, 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/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/modals/BuyMembershipModal/BuyMembershipFormModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx index 9603539a02..498e80d1a4 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipFormModal.tsx @@ -1,11 +1,11 @@ import HCaptcha from '@hcaptcha/react-hcaptcha' import { BalanceOf } from '@polkadot/types/interfaces/runtime' -import React, { useEffect, useMemo, useState } from 'react' +import { uniqBy } from 'lodash' +import React, { useEffect, useState } from 'react' import { FormProvider, useForm } from 'react-hook-form' -import styled from 'styled-components' import * as Yup from 'yup' -import { SelectAccount, SelectedAccount } from '@/accounts/components/SelectAccount' +import { SelectAccount } from '@/accounts/components/SelectAccount' import { useMyAccounts } from '@/accounts/hooks/useMyAccounts' import { accountOrNamed } from '@/accounts/model/accountOrNamed' import { Account } from '@/accounts/types' @@ -21,15 +21,13 @@ import { LabelLink, ToggleCheckbox, } from '@/common/components/forms' -import { Arrow, CrossIcon, PlusIcon } from '@/common/components/icons' -import { AlertSymbol } from '@/common/components/icons/symbols' +import { Arrow } from '@/common/components/icons' import { Loading } from '@/common/components/Loading' import { ModalFooter, ModalFooterGroup, ModalHeader, Row, - RowInline, ScrolledModal, ScrolledModalBody, ScrolledModalContainer, @@ -37,14 +35,14 @@ import { } from '@/common/components/Modal' import { Tooltip, TooltipDefault } from '@/common/components/Tooltip' import { TransactionInfo } from '@/common/components/TransactionInfo' -import { TextMedium, TextSmall } from '@/common/components/typography' +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' -import { useValidators } from '@/validators/hooks/useValidators' import { SelectMember } from '../../components/SelectMember' import { @@ -80,7 +78,6 @@ const CreateMemberSchema = Yup.object().shape({ ), hasTerms: Yup.boolean().required().oneOf([true]), isReferred: Yup.boolean(), - isValidator: Yup.boolean(), referrer: ReferrerSchema, externalResources: ExternalResourcesSchema, }) @@ -93,8 +90,6 @@ export interface MemberFormFields { about: string avatarUri: File | string | null isReferred?: boolean - isValidator?: boolean - validatorAccountCandidate?: Account validatorAccounts?: Account[] referrer?: Member hasTerms?: boolean @@ -109,7 +104,7 @@ const formDefaultValues = { about: '', avatarUri: null, isReferred: false, - isValidator: false, + validatorAccounts: [], referrer: undefined, hasTerms: false, externalResources: {}, @@ -144,26 +139,13 @@ export const BuyMembershipForm = ({ }, }) - const [handle, isReferred, isValidator, referrer, captchaToken, validatorAccountCandidate] = form.watch([ - 'handle', - 'isReferred', - 'isValidator', - 'referrer', - 'captchaToken', - 'validatorAccountCandidate', - ]) + const [handle, isReferred, referrer, captchaToken] = form.watch(['handle', 'isReferred', 'referrer', 'captchaToken']) - const validators = useValidators({ skip: !isValidator ?? true }) - const [validatorAccounts, setValidatorAccounts] = useState([]) - const validatorAddresses = useMemo( - () => validators?.flatMap(({ stashAccount: stash, controllerAccount: ctrl }) => (ctrl ? [stash, ctrl] : [stash])), - [validators] - ) - - const isValidValidatorAccount = useMemo( - () => validatorAccountCandidate && validatorAddresses?.includes(validatorAccountCandidate.address), - [validatorAccountCandidate, validatorAddresses] - ) + const selectValidatorAccounts = useSelectValidatorAccounts() + const { + isValidatorAccount, + state: { isValidator, accounts: validatorAccounts }, + } = selectValidatorAccounts useEffect(() => { if (handle) { @@ -177,19 +159,20 @@ export const BuyMembershipForm = ({ } }, [data?.membershipsConnection.totalCount]) - const isFormValid = !isUploading && form.formState.isValid && (!isValidator || validatorAccounts?.length) + 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 addValidatorAccount = () => { - if (validatorAccountCandidate && isValidValidatorAccount) { - setValidatorAccounts([...new Set([...validatorAccounts, validatorAccountCandidate])]) - form?.setValue('validatorAccountCandidate' as keyof MemberFormFields, undefined) - } - } - - const removeValidatorAccount = (index: number) => { - setValidatorAccounts([...validatorAccounts.slice(0, index), ...validatorAccounts.slice(index + 1)]) + const submit = () => { + const accounts = uniqBy(validatorAccounts as Account[], 'address') + form.setValue('validatorAccounts', accounts) + const values = form.getValues() + uploadAvatarAndSubmit({ ...values, externalResources: { ...definedValues(values.externalResources) } }) } return ( @@ -273,78 +256,7 @@ export const BuyMembershipForm = ({ - {type === 'general' && ( - <> - - - - - {isValidator && ( - <> - - - - - - - * - - - If your validator account is not in your signer wallet, paste the account address to the field - below: - - - - !!validatorAddresses?.includes(account.address)} - /> - - - - - - {validatorAccountCandidate && !isValidValidatorAccount && ( - - - - - - - - This account is neither a validator controller account nor a validator stash account. - - - )} - - - {validatorAccounts.map((account, index) => ( - - - - { - removeValidatorAccount(index) - }} - > - - - - - ))} - - )} - - )} + {type === 'general' && } {process.env.REACT_APP_CAPTCHA_SITE_KEY && type === 'onBoarding' && ( @@ -396,18 +308,7 @@ export const BuyMembershipForm = ({ /> )} - { - validatorAccounts?.map((account, index) => { - form?.register(('validatorAccounts[' + index + ']') as keyof MemberFormFields) - form?.setValue(('validatorAccounts[' + index + ']') as keyof MemberFormFields, account) - }) - const values = form.getValues() - uploadAvatarAndSubmit({ ...values, externalResources: { ...definedValues(values.externalResources) } }) - }} - disabled={isDisabled} - > + {isUploading ? : 'Create a Membership'} @@ -424,25 +325,3 @@ export const BuyMembershipFormModal = ({ onClose, onSubmit, membershipPrice }: B ) } - -export 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/modals/BuyMembershipModal/BuyMembershipSignModal.tsx b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx index 86577ba0ef..3cd2aa8591 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/BuyMembershipSignModal.tsx @@ -68,13 +68,14 @@ export const BuyMembershipSignModal = ({ } }, [!balance, !membershipPrice, !paymentInfo]) + const shouldBindValidatorAccounts = formData.validatorAccounts?.length const signDisabled = !isReady || !hasFunds || !validationInfo return ( - {formData.isValidator + {shouldBindValidatorAccounts ? 'You intend to create a validator membership.' : 'You intend to create a new membership.'} @@ -129,7 +130,7 @@ export const BuyMembershipSignModal = ({ transactionFee={paymentInfo?.partialFee.toBn()} next={{ disabled: signDisabled, - label: formData.isValidator ? 'Create membership' : 'Sign and create a member', + label: shouldBindValidatorAccounts ? 'Create membership' : 'Sign and create a member', onClick: sign, }} > diff --git a/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts b/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts index 44eae36580..814951875c 100644 --- a/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts +++ b/packages/ui/src/memberships/modals/BuyMembershipModal/machine.ts @@ -64,14 +64,14 @@ export const buyMembershipMachine = createMachine getDataFromEvent(event.data.events, 'members', 'MembershipBought', 0), }), - cond: (context, event) => isTransactionSuccess(context, event) && !!context.form?.isValidator, + 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?.isValidator, + cond: (context, event) => isTransactionSuccess(context, event) && !context.form?.validatorAccounts?.length, }, { target: 'error', diff --git a/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx b/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx index 69f38fa2ee..2fc3e34a80 100644 --- a/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx +++ b/packages/ui/src/memberships/modals/UpdateMembershipModal/UpdateMembershipFormModal.tsx @@ -1,46 +1,43 @@ +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' -import { filterAccount, SelectAccount, SelectedAccount } from '@/accounts/components/SelectAccount' +import { filterAccount, SelectAccount } from '@/accounts/components/SelectAccount' import { useMyAccounts } from '@/accounts/hooks/useMyAccounts' import { accountOrNamed } from '@/accounts/model/accountOrNamed' -import { encodeAddress } from '@/accounts/model/encodeAddress' -import { ButtonGhost, ButtonPrimary } from '@/common/components/buttons' -import { InputComponent, InputText, InputTextarea, Label, ToggleCheckbox } from '@/common/components/forms' -import { CrossIcon, PlusIcon } from '@/common/components/icons' +import { Account } from '@/accounts/types' +import { InputComponent, InputText, InputTextarea } from '@/common/components/forms' import { Loading } from '@/common/components/Loading' import { ModalHeader, ModalTransactionFooter, Row, - RowInline, ScrolledModal, ScrolledModalBody, ScrolledModalContainer, } from '@/common/components/Modal' -import { RowGapBlock } from '@/common/components/page/PageContent' -import { Tooltip, TooltipDefault } from '@/common/components/Tooltip' -import { TextMedium, TextSmall } from '@/common/components/typography' +import { TextMedium } from '@/common/components/typography' import { Warning } from '@/common/components/Warning' -import { Address } from '@/common/types' 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' -import { useValidators } from '@/validators/hooks/useValidators' import { AvatarURISchema, ExternalResourcesSchema, HandleSchema } from '../../model/validation' import { MemberWithDetails } from '../../types' -import { SelectValidatorAccountWrapper } from '../BuyMembershipModal/BuyMembershipFormModal' import { UpdateMemberForm } from './types' import { changedOrNull, hasAnyEdits, hasAnyMetadateChanges, membershipExternalResourceToObject } from './utils' +type FormFields = Omit & { + validatorAddresses: string[] +} interface Props { onClose: () => void onSubmit: (params: WithNullableValues, memberId: string, controllerAccount: string) => void @@ -57,26 +54,44 @@ const UpdateMemberSchema = Yup.object().shape({ export const UpdateMembershipFormModal = ({ onClose, onSubmit, member }: Props) => { const { allAccounts } = useMyAccounts() - const validators = useValidators() - const validatorAddresses = useMemo( + + const boundAccounts: Account[] = useMemo( () => - validators - ?.flatMap(({ stashAccount: stash, controllerAccount: ctrl }) => (ctrl ? [stash, ctrl] : [stash])) - .map(encodeAddress), - [validators] - ) - const isValidatorAccount = useCallback( - (address: Address): boolean | undefined => validatorAddresses?.includes(address), - [validatorAddresses] + member.boundAccounts.map( + (address) => + allAccounts.find((account) => account.address === address) ?? + accountOrNamed(allAccounts, address, 'Unsaved account') + ), + [allAccounts, member] ) - const initialValidatorAccounts = useMemo( - () => member.boundAccounts.filter((address) => isValidatorAccount(address)), - [member.boundAccounts, isValidatorAccount] + 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( @@ -84,33 +99,17 @@ export const UpdateMembershipFormModal = ({ onClose, onSubmit, member }: Props) updateMemberFormInitial ), validatorAccounts: isValidator - ? fields.validatorAccounts?.filter((address) => !initialValidatorAccounts.includes(address)) + ? difference(fields.validatorAddresses, updateMemberFormInitial.validatorAddresses) : [], validatorAccountsToBeRemoved: isValidator - ? initialValidatorAccounts.filter((address) => !fields.validatorAccounts?.includes(address)) - : initialValidatorAccounts, + ? difference(updateMemberFormInitial.validatorAddresses, fields.validatorAddresses) + : updateMemberFormInitial.validatorAddresses, }, member.id, member.controllerAccount ) ) - 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, - validatorAccounts: initialValidatorAccounts.length ? [...initialValidatorAccounts] : undefined, - }), - [member, initialValidatorAccounts] - ) - const form = useForm({ resolver: useYupValidationResolver(UpdateMemberSchema), context, @@ -125,15 +124,7 @@ export const UpdateMembershipFormModal = ({ onClose, onSubmit, member }: Props) }) }, [updateMemberFormInitial, member, allAccounts]) - const [controllerAccount, rootAccount, handle, isValidator, validatorAccountCandidate, validatorAccounts] = - form.watch([ - 'controllerAccount', - 'rootAccount', - 'handle', - 'isValidator', - 'validatorAccountCandidate', - 'validatorAccounts', - ]) + const [controllerAccount, rootAccount, handle] = form.watch(['controllerAccount', 'rootAccount', 'handle']) useEffect(() => { form.trigger('handle') @@ -146,33 +137,26 @@ export const UpdateMembershipFormModal = ({ onClose, onSubmit, member }: Props) const filterRoot = useCallback(filterAccount(controllerAccount), [controllerAccount]) const filterController = useCallback(filterAccount(rootAccount), [rootAccount]) + const formData = useMemo( + () => + ({ + ...form.getValues(), + isValidator, + validatorAddresses: validatorAccounts.flatMap((account) => account?.address ?? []), + } as UpdateMemberForm), + [form.getValues(), validatorAccounts] + ) + const canUpdate = form.formState.isValid && - hasAnyEdits(form.getValues(), updateMemberFormInitial) && - (!isValidator || validatorAccounts?.length) + hasAnyEdits(formData, updateMemberFormInitial) && + (!isValidator || + (validatorAccounts.length > 0 && validatorAccounts.every((account) => account && isValidatorAccount(account)))) const willBecomeUnverifiedValidator = - updateMemberFormInitial.isValidator && hasAnyMetadateChanges(form.getValues(), updateMemberFormInitial) - - const addValidatorAccount = () => { - if (validatorAccountCandidate) { - setValidatorAccounts([...new Set([...(validatorAccounts ?? []), validatorAccountCandidate.address])]) - form?.setValue('validatorAccountCandidate' as keyof UpdateMemberForm, undefined) - } - } + updateMemberFormInitial.isValidator && hasAnyMetadateChanges(formData, updateMemberFormInitial) - const removeValidatorAccount = (index: number) => { - validatorAccounts && - setValidatorAccounts([...validatorAccounts.slice(0, index), ...validatorAccounts.slice(index + 1)]) - } - - const setValidatorAccounts = (accounts: Address[]) => { - form?.setValue('validatorAccounts' as keyof UpdateMemberForm, []) - accounts.map((account, index) => { - form?.register(('validatorAccounts[' + index + ']') as keyof UpdateMemberForm) - form?.setValue(('validatorAccounts[' + index + ']') as keyof UpdateMemberForm, account) - }) - } + const submit = () => uploadAvatarAndSubmit(formData as FormFields) return ( @@ -241,65 +225,7 @@ export const UpdateMembershipFormModal = ({ onClose, onSubmit, member }: Props) /> )} - - - - - - {isValidator && ( - <> - - - - - - - * - - - If your validator account is not in your signer wallet, paste the account address to the field - below: - - - - - - - - - - - - - {validatorAccounts?.map((address, index) => ( - - - - { - removeValidatorAccount(index) - }} - className="remove-button" - > - - - - - ))} - - - )} + @@ -307,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/types.ts b/packages/ui/src/memberships/modals/UpdateMembershipModal/types.ts index 22465b2fdc..65c2e3d75d 100644 --- a/packages/ui/src/memberships/modals/UpdateMembershipModal/types.ts +++ b/packages/ui/src/memberships/modals/UpdateMembershipModal/types.ts @@ -10,8 +10,6 @@ export interface UpdateMemberForm { rootAccount?: Account controllerAccount?: Account externalResources: Record - isValidator?: boolean - validatorAccountCandidate?: Account 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 94787bfcee..2499a0c7d5 100644 --- a/packages/ui/src/memberships/modals/UpdateMembershipModal/utils.ts +++ b/packages/ui/src/memberships/modals/UpdateMembershipModal/utils.ts @@ -32,7 +32,6 @@ export const getChangedFields = (form: Record, initial: Record Date: Thu, 15 Feb 2024 13:14:27 +0100 Subject: [PATCH 14/14] Fix the cancel proposal test --- packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx b/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx index 613c03e971..a08a44a29a 100644 --- a/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx +++ b/packages/ui/src/app/pages/Proposals/ProposalPreview.stories.tsx @@ -664,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) }) }) },