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