Skip to content

Commit

Permalink
Validators list page validator card (#4419)
Browse files Browse the repository at this point in the history
* add validator card

* update storybook

* lint fix

* update colums

* add linechart sample

* sort validators list

* fix storybook

* fix validatorlist sort

* fix sortedValidators

* update type of Validator

* update storybook

* fix

* lint fix

* update interaction test, mocking

* Revert "add linechart sample"

This reverts commit 2e7f08b.

* address merge conflicts

* fetch rewardPointHistory

* update validator card

* fix

* Add uptime in depth

* fix uptime percent scale

* address merge conflicts

* fix

* fix merge conflict, update asChainData helper

---------

Co-authored-by: Theophile Sandoz <[email protected]>
  • Loading branch information
eshark9312 and thesan authored Dec 19, 2023
1 parent 5d255ae commit 4b9a450
Show file tree
Hide file tree
Showing 14 changed files with 710 additions and 43 deletions.
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@types/react-transition-group": "^4.4.3",
"@types/styled-components": "^5.1.15",
"@xstate/react": "^1.6.1",
"chart.js": "^4.4.1",
"copy-webpack-plugin": "^9.0.1",
"crypto-browserify": "^3.12.0",
"date-fns": "^2.25.0",
Expand All @@ -67,6 +68,7 @@
"mime": "^2.4.4",
"multihashes": "^4.0.3",
"react": "^17.0.2",
"react-chartjs-2": "^5.2.0",
"react-dom": "^17.0.2",
"react-dropzone": "^11.4.2",
"react-error-boundary": "^3.1.4",
Expand Down
229 changes: 218 additions & 11 deletions packages/ui/src/app/pages/Validators/ValidatorList.stories.tsx

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/ui/src/common/constants/numbers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ 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 ERA_DEPTH = 120
16 changes: 15 additions & 1 deletion packages/ui/src/mocks/helpers/asChainData.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { createType } from '@joystream/types'
import { mapValues } from 'lodash'

import { encodeAddress } from '@/accounts/model/encodeAddress'

export const asChainData = (data: any): any => {
switch (Object.getPrototypeOf(data).constructor.name) {
case 'Object':
Expand All @@ -20,4 +22,16 @@ export const asChainData = (data: any): any => {
}
}

const withUnwrap = (data: Record<any, any>) => Object.defineProperty(data, 'unwrap', { value: () => data })
const withUnwrap = (data: Record<any, any>) =>
Object.defineProperties(data, {
unwrap: { value: () => data },
isSome: { value: Object.keys(data).length > 0 },
get: {
value: (key: any) => {
if (key.toRawType?.() === 'AccountId') {
return data[encodeAddress(key.toString())]
}
return data[key.toString()]
},
},
})
73 changes: 73 additions & 0 deletions packages/ui/src/validators/components/RewardPointChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Chart } from 'chart.js/auto'
import React from 'react'
import { Line } from 'react-chartjs-2'

import { RewardPoints } from '../types'

Chart.register()

interface Data {
rewardPointsHistory: RewardPoints[]
}

type Mode = 'index' | 'dataset' | 'point' | 'nearest' | 'x' | 'y' | undefined

const RewardPointChart = ({ rewardPointsHistory }: Data) => {
const sortedRewardsHistory = rewardPointsHistory.sort((a, b) => a.era - b.era)
const eras = sortedRewardsHistory.map((item) => item.era)
const rewardPoints = sortedRewardsHistory.map((item) => item.rewardPoints)
const averageRewardPoints = rewardPoints.reduce((a, b) => a + b, 0) / rewardPoints.length
const averageLine = Array(eras.length).fill(averageRewardPoints)

const data = {
labels: eras,
datasets: [
{
label: 'Era points',
data: rewardPoints,
fill: false,
borderColor: 'blue',
borderWidth: 2,
tension: 0.2,
pointRadius: 0,
},
{
label: 'Average points',
data: averageLine,
fill: false,
borderColor: 'black',
borderWidth: 1,
pointRadius: 0,
},
],
}

const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false,
},
},
interaction: {
intersect: false,
mode: 'index' as Mode,
},
scales: {
x: {
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
y: {
grid: {
color: 'rgba(0, 0, 0, 0.05)',
},
},
},
}
return <Line data={data} options={options} />
}

export default RewardPointChart
34 changes: 19 additions & 15 deletions packages/ui/src/validators/components/ValidatorInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,26 @@ import { MemberWithDetails } from '@/memberships/types'
interface ValidatorInfoProps {
address: Address
member?: MemberWithDetails
isOnDark?: boolean
size?: 's' | 'l'
}

export const ValidatorInfo = React.memo(({ address, member }: ValidatorInfoProps) => {
export const ValidatorInfo = React.memo(({ address, member, size = 's' }: ValidatorInfoProps) => {
const twitter = member?.externalResources?.find(({ source }) => source === 'TWITTER')
const telegram = member?.externalResources?.find(({ source }) => source === 'TELEGRAM')
const discord = member?.externalResources?.find(({ source }) => source === 'DISCORD')

return (
<ValidatorInfoWrap>
<ValidatorInfoWrap size={size}>
<PhotoWrapper>
<AccountPhoto>
{member ? <Avatar avatarUri={member.avatar} /> : <Identicon size={40} theme={'beachball'} value={address} />}
<AccountPhoto size={size}>
{member ? (
<Avatar avatarUri={member.avatar} />
) : (
<Identicon size={size === 'l' ? 80 : 40} theme={'beachball'} value={address} />
)}
</AccountPhoto>
</PhotoWrapper>
<ValidatorHandle className="accountName">
<ValidatorHandle className="accountName" size={size}>
{member?.handle ?? 'Unknown'}
{(twitter || telegram || discord) && (
<MemberIcons>
Expand Down Expand Up @@ -63,28 +67,26 @@ export const ValidatorInfo = React.memo(({ address, member }: ValidatorInfoProps
)
})

const ValidatorInfoWrap = styled.div`
const ValidatorInfoWrap = styled.div<{ size?: 's' | 'l' }>`
display: grid;
grid-template-columns: 40px 1fr;
grid-template-rows: min-content 24px 18px;
grid-template-columns: ${({ size }) => (size === 'l' ? '80px' : '40px')} 1fr;
grid-column-gap: 12px;
grid-template-areas:
'accountphoto accounttype'
'accountphoto accountname'
'accountphoto accountaddress';
align-items: center;
width: 100%;
justify-self: start;
`

const AccountPhoto = styled.div`
const AccountPhoto = styled.div<{ size?: 's' | 'l' }>`
display: flex;
justify-content: flex-end;
align-items: center;
align-content: center;
align-self: center;
height: 40px;
width: 40px;
height: ${({ size }) => (size === 'l' ? '80px' : '40px')};
width: ${({ size }) => (size === 'l' ? '80px' : '40px')};
border-radius: ${BorderRad.full};
overflow: hidden;
`
Expand All @@ -94,12 +96,12 @@ const PhotoWrapper = styled.div`
position: relative;
`

const ValidatorHandle = styled.h5`
const ValidatorHandle = styled.h5<{ size?: 's' | 'l' }>`
grid-area: accountname;
max-width: 100%;
margin: 0;
padding: 0;
font-size: 16px;
font-size: ${({ size }) => (size === 'l' ? '20px' : '16px')};
line-height: 24px;
font-weight: 700;
color: ${Colors.Black[900]};
Expand All @@ -113,10 +115,12 @@ const ValidatorHandle = styled.h5`
grid-column-gap: 4px;
align-items: center;
width: fit-content;
margin-top: auto;
`

const AccountCopyAddress = styled(CopyComponent)`
grid-area: accountaddress;
margin-bottom: auto;
`
const SocialTooltip = styled(DefaultTooltip)`
> svg {
Expand Down
10 changes: 6 additions & 4 deletions packages/ui/src/validators/components/ValidatorItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,19 @@ import { BorderRad, Colors, Sizes, Transitions } from '@/common/constants'
import { ValidatorWithDetails } from '../types/Validator'

import { ValidatorInfo } from './ValidatorInfo'

interface ValidatorItemProps {
validator: ValidatorWithDetails
onClick?: () => void
}
export const ValidatorItem = ({ validator }: ValidatorItemProps) => {
const { stashAccount, membership, isVerified, isActive, commission, APR, staking } = validator
export const ValidatorItem = ({ validator, onClick }: ValidatorItemProps) => {
const { stashAccount, membership, isVerifiedValidator, isActive, commission, APR, staking } = validator

return (
<ValidatorItemWrapper>
<ValidatorItemWrapper onClick={onClick}>
<ValidatorItemWrap>
<ValidatorInfo member={membership} address={encodeAddress(stashAccount)} />
{isVerified ? (
{isVerifiedValidator ? (
<BadgeStatus inverted size="l">
verified
</BadgeStatus>
Expand Down
19 changes: 17 additions & 2 deletions packages/ui/src/validators/components/ValidatorsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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 { ValidatorItem } from './ValidatorItem'
Expand All @@ -19,6 +20,7 @@ interface ValidatorsListProps {
}

export const ValidatorsList = ({ validators }: ValidatorsListProps) => {
const [cardNumber, selectCard] = useState<number | null>(null)
type SortKey = 'stashAccount' | 'APR' | 'commission'
const [sortBy, setSortBy] = useState<SortKey>('stashAccount')
const [isDescending, setDescending] = useState(false)
Expand Down Expand Up @@ -87,12 +89,25 @@ export const ValidatorsList = ({ validators }: ValidatorsListProps) => {
</SortHeader>
</ListHeaders>
<List>
{sortedValidators?.map((validator) => (
<ListItem key={validator.stashAccount}>
{sortedValidators?.map((validator, index) => (
<ListItem
key={validator.stashAccount}
onClick={() => {
selectCard(index + 1)
}}
>
<ValidatorItem validator={validator} />
</ListItem>
))}
</List>
{cardNumber && sortedValidators[cardNumber - 1] && (
<ValidatorCard
cardNumber={cardNumber}
validator={sortedValidators[cardNumber - 1]}
selectCard={selectCard}
totalCards={sortedValidators.length}
/>
)}
</ValidatorsListWrap>
)
}
Expand Down
34 changes: 25 additions & 9 deletions packages/ui/src/validators/hooks/useValidatorsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ 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'
Expand All @@ -21,15 +22,22 @@ export const useValidatorsList = () => {
const [visibleValidators, setVisibleValidators] = useState<ValidatorWithDetails[]>([])
const validators = useValidatorMembers()

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<ValidatorWithDetails> => {
if (!activeValidators || !validatorRewardPointsHistory) return of()
const { stashAccount: address, commission } = validator
const activeValidators$ = api.query.session.validators()
const stakingInfo$ = api.query.staking
.activeEra()
.pipe(switchMap((activeEra) => api.query.staking.erasStakers(activeEra.unwrap().index, address)))
const rewardHistory$ = api.derive.staking.stakerRewards(address)
return combineLatest([activeValidators$, stakingInfo$, rewardHistory$]).pipe(
map(([activeValidators, stakingInfo, rewardHistory]) => {
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)
Expand All @@ -39,13 +47,18 @@ export const useValidatorsList = () => {
.div(stakingInfo.total.toBn())
.toNumber()
: 0
const validatorMembership = validators?.find(({ stashAccount }) => stashAccount === address)
const rewardPointsHistory = validatorRewardPointsHistory.map((entry) => ({
era: entry[0].args[0].toNumber(),
rewardPoints: entry[1].individual.get(createType('AccountId', address))?.toNumber() ?? 0,
}))
return {
...validator,
isVerified: validatorMembership?.isVerifiedValidator,
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(),
Expand All @@ -65,8 +78,11 @@ export const useValidatorsList = () => {
}

const allValidatorsWithDetails = useFirstObservableValue(
() => (api && validators ? getValidatorsInfo(api, validators) : of([])),
[api?.isConnected, validators]
() =>
api && validators && validatorRewardPointsHistory && activeValidators
? getValidatorsInfo(api, validators)
: of([]),
[api?.isConnected, validators, validatorRewardPointsHistory, activeValidators]
)

useEffect(() => {
Expand All @@ -79,8 +95,8 @@ export const useValidatorsList = () => {
else return true
})
.filter((validator) => {
if (isVerified === 'verified') return validator.isVerified
else if (isVerified === 'unverified') return !validator.isVerified
if (isVerified === 'verified') return validator.isVerifiedValidator
else if (isVerified === 'unverified') return !validator.isVerifiedValidator
else return true
})
.filter((validator) => {
Expand Down
Loading

2 comments on commit 4b9a450

@vercel
Copy link

@vercel vercel bot commented on 4b9a450 Dec 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 4b9a450 Dec 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

pioneer-2 – ./

pioneer-2-git-dev-joystream.vercel.app
pioneer-2-joystream.vercel.app
pioneer-2.vercel.app

Please sign in to comment.