Additional alignment of staking UI layout (#8828)

* Additional alignment of staking UI layout

* Allow for validator tagging & sorting

* Adjust flag injection (re-create object)

* currentEra for staking queries

* Pass SessionInfo structure

* Display badges

* Adjust flags

* Adjust status media

* Badge background adjustments

* Don't do additional table split

* Adjust table split
This commit is contained in:
Jaco 2023-01-17 11:30:34 +02:00 committed by GitHub
parent 39de5d761a
commit 6ecccdc031
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 637 additions and 155 deletions

View File

@ -66,6 +66,7 @@
/* --color-table-foot: rgba(254, 240, 240, 0.66); */
/* --color-table-head: rgba(254, 240, 240, 0.8); */
--color-text: rgba(244, 242, 240, 0.8);
--opacity-gray: 0.1;
}
[data-theme="light"] {
@ -90,6 +91,7 @@
/* --color-table-foot: rgba(78, 78, 78, 0.66); */
/* --color-table-head: rgba(78, 78, 78, 0.8); */
--color-text: rgba(78, 78, 78, 0.8); /* 1 */
--opacity-gray: 0.75;
}
</style>
<style>

View File

@ -176,7 +176,7 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
Object.values(ret).forEach((a): void => {
a[0][1] = 'start';
a[0][2] = 3;
a[0][2] = 4;
});
return ret;

View File

@ -7,6 +7,8 @@ import type { EventRecord } from '@polkadot/types/interfaces';
import { createNamedHook, useApi, useEventChanges, useMapKeys } from '@polkadot/react-hooks';
const EMPTY_PARAMS: unknown[] = [];
const OPT_KEY = {
transform: (keys: StorageKey<[u32]>[]): u32[] =>
keys.map(({ args: [id] }) => id)
@ -29,7 +31,7 @@ function filter (records: EventRecord[]): Changes<u32> {
function useAssetIdsImpl (): u32[] | undefined {
const { api } = useApi();
const startValue = useMapKeys(api.query.assets.asset, [], OPT_KEY);
const startValue = useMapKeys(api.query.assets.asset, EMPTY_PARAMS, OPT_KEY);
return useEventChanges([
api.events.assets.Created,

View File

@ -0,0 +1,93 @@
// Copyright 2017-2023 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Validator } from '../../types';
import type { Heartbeat } from './types';
import React, { useMemo } from 'react';
import { Badge } from '@polkadot/react-components';
import { useAccounts } from '@polkadot/react-hooks';
interface Props {
heartbeat: Heartbeat;
isChilled?: boolean;
isPara?: boolean;
isRelay?: boolean;
nominators?: string[];
validator: Validator;
}
function Status ({ heartbeat: { authoredBlocks, isOnline }, isChilled, isPara, isRelay, nominators, validator: { isElected } }: Props): React.ReactElement<Props> {
const { allAccounts } = useAccounts();
const isNominating = useMemo(
() => nominators && nominators.some((a) => allAccounts.includes(a)),
[allAccounts, nominators]
);
const emptyBadge = (
<Badge
className='opaque'
color='gray'
/>
);
return (
<div>
{isNominating
? (
<Badge
color='green'
icon='hand-paper'
/>
)
: emptyBadge
}
{isRelay && (
isPara
? (
<Badge
color='purple'
icon='vector-square'
/>
)
: emptyBadge
)}
{isChilled
? (
<Badge
color='red'
icon='cancel'
/>
)
: isElected
? (
<Badge
color='blue'
icon='chevron-right'
/>
)
: emptyBadge
}
{isOnline
? authoredBlocks
? (
<Badge
color='green'
info={authoredBlocks}
/>
)
: (
<Badge
color='green'
icon='envelope'
/>
)
: emptyBadge
}
</div>
);
}
export default React.memo(Status);

View File

@ -1,43 +1,48 @@
// Copyright 2017-2023 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { BN } from '@polkadot/util';
import type { SessionInfo, Validator } from '../../types';
import React, { useMemo } from 'react';
import { AddressSmall, Table, Tag } from '@polkadot/react-components';
import { useToggle } from '@polkadot/react-hooks';
import { FormatBalance } from '@polkadot/react-query';
import { formatNumber } from '@polkadot/util';
import Status from './Status';
import useExposure from './useExposure';
import useHeartbeat from './useHeartbeat';
interface Props {
activeEra: BN | null;
className?: string;
isFavorite: boolean;
points?: string;
stashId: string;
isRelay: boolean;
points?: number;
sessionInfo: SessionInfo;
toggleFavorite: (stashId: string) => void;
validator: Validator;
}
interface PropsExpanded {
className?: string;
stashId: string;
validator: Validator;
}
function ValidatorExpanded ({ className = '' }: PropsExpanded): React.ReactElement<PropsExpanded> {
return (
<tr className={`${className} isExpanded isLast`}>
<td />
<td></td>
<td />
<td />
<td />
</tr>
);
}
function Validator ({ activeEra, className = '', isFavorite, points, stashId, toggleFavorite }: Props): React.ReactElement<Props> {
function Validator ({ className = '', isRelay, points, sessionInfo, toggleFavorite, validator }: Props): React.ReactElement<Props> {
const [isExpanded, toggleExpanded] = useToggle();
const exposure = useExposure(stashId, activeEra);
const exposure = useExposure(validator, sessionInfo);
const heartbeat = useHeartbeat(validator, sessionInfo);
const pointsAnimClass = useMemo(
() => (points && `greyAnim-${Date.now() % 25}`) || '',
@ -48,17 +53,27 @@ function Validator ({ activeEra, className = '', isFavorite, points, stashId, to
<>
<tr className={`${className} isExpanded isFirst packedBottom`}>
<Table.Column.Favorite
address={stashId}
isFavorite={isFavorite}
address={validator.stashId}
isFavorite={validator.isFavorite}
toggle={toggleFavorite}
/>
<td
className='statusInfo'
rowSpan={2}
>
<Status
heartbeat={heartbeat}
isRelay={isRelay}
validator={validator}
/>
</td>
<td className='address relative all'>
<AddressSmall value={stashId} />
<AddressSmall value={validator.stashId} />
{points && (
<Tag
className={`${pointsAnimClass} absolute`}
color='lightgrey'
label={points}
label={formatNumber(points)}
/>
)}
</td>
@ -70,6 +85,7 @@ function Validator ({ activeEra, className = '', isFavorite, points, stashId, to
<tr className={`${className} isExpanded ${isExpanded ? '' : 'isLast'} packedTop`}>
<td />
<Table.Column.Balance
className='relative'
label={
exposure?.waiting && (
<>
@ -85,7 +101,7 @@ function Validator ({ activeEra, className = '', isFavorite, points, stashId, to
<td />
</tr>
{isExpanded && (
<ValidatorExpanded stashId={stashId} />
<ValidatorExpanded validator={validator} />
)}
</>
);

View File

@ -0,0 +1,7 @@
// Copyright 2017-2023 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
export interface Heartbeat {
authoredBlocks?: number;
isOnline: boolean;
}

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
import type { PalletStakingExposure } from '@polkadot/types/lookup';
import type { SessionInfo, Validator } from '../../types';
import { useMemo } from 'react';
@ -31,11 +32,11 @@ interface Result {
const OPT_EXPOSURE = {
transform: ({ others, own, total }: PalletStakingExposure): Exposure => ({
others: others
.sort((a, b) => a.value.unwrap().cmp(b.value.unwrap()))
.map(({ value, who }) => ({
value: value.unwrap(),
who: who.toString()
})),
}))
.sort((a, b) => b.value.cmp(a.value)),
own: own.unwrap(),
total: total.unwrap()
})
@ -52,23 +53,26 @@ function getResult (exposure?: Exposure, clipped?: Exposure): Result {
);
if (others.length) {
waiting = { others, total: others.reduce((total, { value }) => total.iadd(value), new BN(0)) };
waiting = {
others,
total: others.reduce((total, { value }) => total.iadd(value), new BN(0))
};
}
}
return { clipped, exposure, waiting };
}
function useExposureImpl (stashId: string, activeEra: BN | null): Result | undefined {
function useExposureImpl ({ stashId }: Validator, { activeEra }: SessionInfo): Result | undefined {
const { api } = useApi();
const exposureParams = useMemo(
const params = useMemo(
() => activeEra && [activeEra, stashId],
[activeEra, stashId]
);
const fullExposure = useCall(api.query.staking.erasStakers, exposureParams, OPT_EXPOSURE);
const clipExposure = useCall(api.query.staking.erasStakersClipped, exposureParams, OPT_EXPOSURE);
const fullExposure = useCall(params && api.query.staking.erasStakers, params, OPT_EXPOSURE);
const clipExposure = useCall(params && api.query.staking.erasStakersClipped, params, OPT_EXPOSURE);
return useMemo(
() => getResult(fullExposure, clipExposure),

View File

@ -0,0 +1,47 @@
// Copyright 2017-2023 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option, u32 } from '@polkadot/types';
import type { Codec } from '@polkadot/types/types';
import type { SessionInfo, Validator } from '../../types';
import type { Heartbeat } from './types';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks';
const OPT_BLOCKS = {
transform: (authoredBlocks: u32): number =>
authoredBlocks.toNumber()
};
const OPT_BEATS = {
// Option<WrapperOpaque<PalletImOnlineBoundedOpaqueNetworkState>>
transform: (receivedHeartbeats: Option<Codec>): boolean =>
receivedHeartbeats.isSome
};
function useHeartbeatImpl ({ stashId, stashIndex }: Validator, { currentSession }: SessionInfo): Heartbeat {
const { api } = useApi();
const params = useMemo(
() => currentSession && ({
authoredBlocks: [currentSession, stashId],
receivedHeartbeats: [currentSession, stashIndex]
}),
[currentSession, stashId, stashIndex]
);
const authoredBlocks = useCall(params && api.query.imOnline.authoredBlocks, params?.authoredBlocks, OPT_BLOCKS);
const receivedHeartbeats = useCall(params && api.query.imOnline.receivedHeartbeats, params?.receivedHeartbeats, OPT_BEATS);
return useMemo(
() => ({
authoredBlocks,
isOnline: !!(authoredBlocks || receivedHeartbeats)
}),
[authoredBlocks, receivedHeartbeats]
);
}
export default createNamedHook('useHeartbeat', useHeartbeatImpl);

View File

@ -1,78 +1,85 @@
// Copyright 2017-2023 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SessionInfo } from '../types';
import type { SessionInfo, Validator } from '../types';
import React, { useRef } from 'react';
import styled from 'styled-components';
import Legend from '@polkadot/app-staking/Legend';
import { Table } from '@polkadot/react-components';
import { useTranslation } from '../translate';
import usePoints from './usePoints';
import Validator from './Validator';
import ValidatorRow from './Validator';
interface Props {
className?: string;
isRelay: boolean;
sessionInfo: SessionInfo;
toggleFavorite: (stashId: string) => void;
validatorsActive?: string[];
validatorsFavorite?: string[];
validatorsSession?: Validator[];
}
function Validators ({ className = '', sessionInfo: { activeEra }, toggleFavorite, validatorsActive, validatorsFavorite }: Props): React.ReactElement<Props> {
function Validators ({ className = '', isRelay, sessionInfo, toggleFavorite, validatorsSession }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const points = usePoints(activeEra);
const points = usePoints(sessionInfo);
const headerActive = useRef<[string?, string?, number?][]>([
// favorite, details, expand
[t<string>('validators'), 'start', 3]
]);
const headerFavorite = useRef<[string?, string?, number?][]>([
// favorite, details, expand
[t<string>('favorites'), 'start', 3]
// favorite, badges, details, expand
[t<string>('validators'), 'start', 4]
]);
return (
<>
{validatorsFavorite && (
<Table
className={className}
header={headerFavorite.current}
isSplit
>
{validatorsFavorite.map((stashId) => (
<Validator
activeEra={activeEra}
isFavorite
key={stashId}
points={points?.[stashId]}
stashId={stashId}
toggleFavorite={toggleFavorite}
/>
))}
</Table>
)}
<Table
className={className}
empty={validatorsActive && t<string>('No session validators found')}
emptySpinner={t<string>('Retrieving session validators')}
header={headerActive.current}
isSplit
>
{validatorsActive?.map((stashId) => (
<Validator
activeEra={activeEra}
isFavorite={false}
key={stashId}
points={points?.[stashId]}
stashId={stashId}
toggleFavorite={toggleFavorite}
/>
))}
</Table>
</>
<StyledTable
className={className}
empty={validatorsSession && t<string>('No session validators found')}
emptySpinner={t<string>('Retrieving session validators')}
header={headerActive.current}
isSplit
legend={
<Legend
isRelay={isRelay}
/>
}
>
{validatorsSession?.map((v) => (
<ValidatorRow
isRelay={isRelay}
key={v.key}
points={points?.[v.stashId]}
sessionInfo={sessionInfo}
toggleFavorite={toggleFavorite}
validator={v}
/>
))}
</StyledTable>
);
}
const StyledTable = styled(Table)`
td.statusInfo {
padding: 0 0 0 0.5rem;
vertical-align: middle;
> div {
display: inline-block;
max-width: 3.6rem;
min-width: 3.6rem;
.ui--Badge {
margin: 0.125rem;
&.opaque {
opacity: var(--opacity-gray);
}
}
}
+ td.address {
padding-left: 0.5rem;
}
}
`;
export default React.memo(Validators);

View File

@ -2,35 +2,35 @@
// SPDX-License-Identifier: Apache-2.0
import type { PalletStakingEraRewardPoints } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
import type { SessionInfo } from '../types';
import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks';
import { BN_ZERO, formatNumber } from '@polkadot/util';
import { BN_ZERO } from '@polkadot/util';
type Result = Record<string, string>;
type Result = Record<string, number>;
const OPT_POINTS = {
transform: ({ individual }: PalletStakingEraRewardPoints): Result =>
[...individual.entries()]
.filter(([, points]) => points.gt(BN_ZERO))
.reduce((result: Result, [stashId, points]): Result => {
result[stashId.toString()] = formatNumber(points);
result[stashId.toString()] = points.toNumber();
return result;
}, {})
};
function usePointsImpl (activeEra: BN | null): Result | undefined {
function usePointsImpl ({ activeEra }: SessionInfo): Result | undefined {
const { api } = useApi();
const pointsParams = useMemo(
const queryParams = useMemo(
() => activeEra && [activeEra],
[activeEra]
);
return useCall(activeEra && api.query.staking.erasRewardPoints, pointsParams, OPT_POINTS);
return useCall(queryParams && api.query.staking.erasRewardPoints, queryParams, OPT_POINTS);
}
export default createNamedHook('usePoints', usePointsImpl);

View File

@ -7,7 +7,7 @@ import React, { useMemo, useRef } from 'react';
import { Route, Switch } from 'react-router';
import { Tabs } from '@polkadot/react-components';
import { useFavorites } from '@polkadot/react-hooks';
import { useApi, useFavorites } from '@polkadot/react-hooks';
import { STORE_FAVS_BASE } from './constants';
import { useTranslation } from './translate';
@ -15,27 +15,16 @@ import useSessionInfo from './useSessionInfo';
import useValidatorsActive from './useValidatorsActive';
import Validators from './Validators';
function splitValidators (favorites: string[], validators?: string[]): { validatorsActive?: string[], validatorsFavorite?: string[] } {
if (!validators) {
return {};
}
const validatorsFavorite = validators.filter((a) => favorites.includes(a));
return validatorsFavorite.length
? { validatorsActive: validators.filter((a) => !validatorsFavorite.includes(a)), validatorsFavorite }
: { validatorsActive: validators };
}
function StakingApp ({ basePath }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api } = useApi();
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS_BASE);
const validators = useValidatorsActive();
const sessionInfo = useSessionInfo();
const validatorsActive = useValidatorsActive(favorites, sessionInfo);
const { validatorsActive, validatorsFavorite } = useMemo(
() => splitValidators(favorites, validators),
[favorites, validators]
const isRelay = useMemo(
() => !!(api.query.parasShared || api.query.shared)?.activeValidatorIndices,
[api]
);
const itemsRef = useRef([
@ -55,10 +44,10 @@ function StakingApp ({ basePath }: Props): React.ReactElement<Props> {
<Switch>
<Route>
<Validators
isRelay={isRelay}
sessionInfo={sessionInfo}
toggleFavorite={toggleFavorite}
validatorsActive={validatorsActive}
validatorsFavorite={validatorsFavorite}
validatorsSession={validatorsActive}
/>
</Route>
</Switch>

View File

@ -4,5 +4,16 @@
import type { BN } from '@polkadot/util';
export interface SessionInfo {
activeEra: BN | null;
activeEra?: BN | null;
currentEra?: BN | null;
currentSession?: BN | null;
}
export interface Validator {
isElected: boolean;
isFavorite: boolean;
isOwned: boolean;
key: string;
stashId: string;
stashIndex: number;
}

View File

@ -0,0 +1,60 @@
// Copyright 2017-2023 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { StorageKey, u32 } from '@polkadot/types';
import type { AccountId32 } from '@polkadot/types/interfaces';
import type { SessionInfo } from './types';
import { useEffect, useMemo } from 'react';
import { createNamedHook, useApi, useMapKeys } from '@polkadot/react-hooks';
interface Cache {
currentEra: SessionInfo['currentEra'];
elected?: string[];
}
const OPT_ELECTED = {
transform: (keys: StorageKey<[u32, AccountId32]>[]): string[] =>
keys.map(({ args: [, stashId] }) =>
stashId.toString()
)
};
// since this would only change once per era for the full runtime,
// we just store it globally to not have expensive map-key lookups
const cached: Cache = {
currentEra: null
};
function useElectedValidatorsImpl ({ currentEra }: SessionInfo): string[] | undefined {
const { api } = useApi();
const electedParams = useMemo(
() => currentEra && (!cached.currentEra || !cached.currentEra.eq(currentEra))
? [currentEra]
: null,
[currentEra]
);
const elected = useMapKeys(electedParams && api.query.staking.erasStakers, electedParams, OPT_ELECTED);
useEffect((): void => {
if (currentEra && elected) {
cached.currentEra = currentEra;
cached.elected = elected;
}
}, [currentEra, elected]);
return useMemo(
() => (
currentEra &&
cached.currentEra &&
cached.currentEra.eq(currentEra) &&
cached.elected
) || elected,
[currentEra, elected]
);
}
export default createNamedHook('useElectedValidators', useElectedValidatorsImpl);

View File

@ -1,7 +1,7 @@
// Copyright 2017-2023 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Option } from '@polkadot/types';
import type { Option, u32 } from '@polkadot/types';
import type { PalletStakingActiveEraInfo } from '@polkadot/types/lookup';
import type { BN } from '@polkadot/util';
import type { SessionInfo } from './types';
@ -10,8 +10,6 @@ import { useMemo } from 'react';
import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks';
const EMPTY_RESULT: SessionInfo = { activeEra: null };
const OPT_ACTIVEERA = {
transform: (activeEra: Option<PalletStakingActiveEraInfo>): BN | null =>
activeEra.isSome
@ -19,15 +17,20 @@ const OPT_ACTIVEERA = {
: null
};
const OPT_CURRENTERA = {
transform: (currentEra: Option<u32>): BN | null =>
currentEra.unwrapOr(null)
};
function useSessionInfoImpl (): SessionInfo {
const { api } = useApi();
const activeEra = useCall(api.query.staking.activeEra, undefined, OPT_ACTIVEERA);
const currentEra = useCall(api.query.staking.currentEra, undefined, OPT_CURRENTERA);
const currentSession = useCall<u32>(api.query.session.currentIndex);
return useMemo(
() => activeEra
? { activeEra }
: EMPTY_RESULT,
[activeEra]
() => ({ activeEra, currentEra, currentSession }),
[activeEra, currentEra, currentSession]
);
}

View File

@ -0,0 +1,65 @@
// Copyright 2017-2023 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SessionInfo, Validator } from './types';
import { useMemo } from 'react';
import { createNamedHook, useAccounts } from '@polkadot/react-hooks';
import { objectSpread } from '@polkadot/util';
import useElectedValidators from './useElectedValidators';
function sort (a: Validator, b: Validator): number {
return a.isFavorite === b.isFavorite
? a.isOwned === b.isOwned
? 0
: a.isOwned
? -1
: 1
: a.isFavorite
? -1
: 1;
}
function withElected (validators: Validator[], elected?: string[]): Validator[] {
return elected
? validators.map((v): Validator => {
const isElected = elected.includes(v.stashId);
return v.isElected !== isElected
? objectSpread({}, v, { isElected })
: v;
})
: validators;
}
function withSort (allAccounts: string[], favorites: string[], validators: Validator[]): Validator[] {
return validators
.map((v): Validator => {
const isFavorite = favorites.includes(v.stashId);
const isOwned = allAccounts.includes(v.stashId);
return v.isFavorite !== isFavorite || v.isOwned !== isOwned
? objectSpread({}, v, { isFavorite, isOwned })
: v;
})
.sort(sort);
}
function useTaggedValidatorsImpl (favorites: string[], sessionInfo: SessionInfo, validators?: Validator[]): Validator[] | undefined {
const { allAccounts } = useAccounts();
const elected = useElectedValidators(sessionInfo);
const flagged = useMemo(
() => validators && withElected(validators, elected),
[elected, validators]
);
return useMemo(
() => flagged && withSort(allAccounts, favorites, flagged),
[allAccounts, favorites, flagged]
);
}
export default createNamedHook('useTaggedValidators', useTaggedValidatorsImpl);

View File

@ -2,18 +2,33 @@
// SPDX-License-Identifier: Apache-2.0
import type { AccountId32 } from '@polkadot/types/interfaces';
import type { SessionInfo, Validator } from './types';
import { createNamedHook, useApi, useCall } from '@polkadot/react-hooks';
import useTaggedValidators from './useTaggedValidators';
const OPT_VALIDATORS = {
transform: (validators: AccountId32[]): string[] =>
validators.map((a) => a.toString())
transform: (validators: AccountId32[]): Validator[] =>
validators.map((a, stashIndex) => {
const stashId = a.toString();
return {
isElected: false,
isFavorite: false,
isOwned: false,
key: `${stashId}:${stashIndex}`,
stashId,
stashIndex
};
})
};
function useValidatorsActiveImpl (): string[] | undefined {
function useValidatorsActiveImpl (favorites: string[], sessionInfo: SessionInfo): Validator[] | undefined {
const { api } = useApi();
const validators = useCall(api.query.session.validators, undefined, OPT_VALIDATORS);
return useCall(api.query.session.validators, undefined, OPT_VALIDATORS);
return useTaggedValidators(favorites, sessionInfo, validators);
}
export default createNamedHook('useValidatorsActive', useValidatorsActiveImpl);

View File

@ -0,0 +1,68 @@
// Copyright 2017-2023 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { Changes } from '@polkadot/react-hooks/useEventChanges';
import type { StorageKey } from '@polkadot/types';
import type { AccountId32, EventRecord } from '@polkadot/types/interfaces';
import type { SessionInfo, Validator } from './types';
import { useMemo } from 'react';
import { createNamedHook, useApi, useEventChanges, useMapKeys } from '@polkadot/react-hooks';
import useTaggedValidators from './useTaggedValidators';
const EMPTY_PARAMS: unknown[] = [];
const OPT_VALIDATORS = {
transform: (keys: StorageKey<[AccountId32]>[]): AccountId32[] =>
keys.map(({ args: [id] }) => id)
};
function eventFilter (records: EventRecord[]): Changes<AccountId32> {
const added: AccountId32[] = [];
const removed: AccountId32[] = [];
records.forEach(({ event: { data: [id], method } }): void => {
if (method === 'Bonded') {
added.push(id as AccountId32);
} else {
removed.push(id as AccountId32);
}
});
return { added, removed };
}
function mapValidators (validators?: AccountId32[]): Validator[] | undefined {
return validators && validators.map((a) => {
const stashId = a.toString();
return {
isElected: false,
isFavorite: false,
isOwned: false,
key: `${stashId}::-1`,
stashId,
stashIndex: -1
};
});
}
function useValidatorsAllImpl (favorites: string[], sessionInfo: SessionInfo): Validator[] | undefined {
const { api } = useApi();
const startValue = useMapKeys(api.query.staking.validators, EMPTY_PARAMS, OPT_VALIDATORS);
const validators = useEventChanges([
api.events.staking.Bonded
], eventFilter, startValue);
const validatorsIndexed = useMemo(
() => mapValidators(validators),
[validators]
);
return useTaggedValidators(favorites, sessionInfo, validatorsIndexed);
}
export default createNamedHook('useValidatorsAll', useValidatorsAllImpl);

View File

@ -0,0 +1,31 @@
// Copyright 2017-2023 @polkadot/app-staking authors & contributors
// SPDX-License-Identifier: Apache-2.0
import type { SessionInfo, Validator } from './types';
import { useMemo } from 'react';
import { createNamedHook } from '@polkadot/react-hooks';
import useValidatorsActive from './useValidatorsActive';
import useValidatorsAll from './useValidatorsAll';
function excludeValidators (from?: Validator[], exclude?: Validator[]): Validator[] | undefined {
return from && exclude && from.filter(({ stashId }) =>
!exclude.some((v) => v.stashId === stashId)
);
}
function useValidatorsWaitingImpl (favorites: string[], sessionInfo: SessionInfo): Validator[] | undefined {
const activeValidators = useValidatorsActive(favorites, sessionInfo);
const allValidators = useValidatorsAll(favorites, sessionInfo);
// both active and all is already sorted and tagged, so we don't
// need to re-sort the waiting list
return useMemo(
() => excludeValidators(allValidators, activeValidators),
[activeValidators, allValidators]
);
}
export default createNamedHook('useValidatorsWaiting', useValidatorsWaitingImpl);

View File

@ -8,6 +8,7 @@ import React from 'react';
import { FormatBalance } from '@polkadot/react-query';
export interface Props {
children?: React.ReactNode;
className?: string;
colSpan?: number;
label?: React.ReactNode;
@ -17,7 +18,7 @@ export interface Props {
withLoading?: boolean;
}
function Balance ({ className = '', colSpan, label, labelPost, rowSpan, value, withLoading }: Props): React.ReactElement<Props> {
function Balance ({ children, className = '', colSpan, label, labelPost, rowSpan, value, withLoading }: Props): React.ReactElement<Props> {
return (
<td
className={`${className} ui--Table-Column-Balance number`}
@ -39,6 +40,7 @@ function Balance ({ className = '', colSpan, label, labelPost, rowSpan, value, w
/>
)
}
{children}
</td>
);
}

View File

@ -9,13 +9,14 @@ import styled from 'styled-components';
import { formatNumber } from '@polkadot/util';
export interface Props {
children?: React.ReactNode;
className?: string;
colSpan?: number;
rowSpan?: number;
value: BN | number;
}
function Id ({ className = '', colSpan, rowSpan, value }: Props): React.ReactElement<Props> {
function Id ({ children, className = '', colSpan, rowSpan, value }: Props): React.ReactElement<Props> {
return (
<StyledTd
className={`${className} ui--Table-Column-Id`}
@ -23,6 +24,7 @@ function Id ({ className = '', colSpan, rowSpan, value }: Props): React.ReactEle
rowSpan={rowSpan}
>
<h2 className='--digits'>{formatNumber(value)}</h2>
{children}
</StyledTd>
);
}

View File

@ -4,7 +4,8 @@
import React, { useMemo } from 'react';
import styled from 'styled-components';
import Columar from '../Columar';
import { useWindowColumns } from '@polkadot/react-hooks';
import Body from './Body';
import Column from './Column';
import Foot from './Foot';
@ -24,45 +25,62 @@ interface Props {
isInline?: boolean;
isSplit?: boolean;
legend?: React.ReactNode;
maxColumns?: 2 | 3;
noBodyTag?: boolean;
}
function extractBody (children: React.ReactNode, isSplit?: boolean): { body: React.ReactNode | React.ReactNode[], bodySplit: [React.ReactNode[], React.ReactNode[]] | null, isEmpty: boolean } {
interface BodyState {
body: React.ReactNode | React.ReactNode[];
isEmpty: boolean;
isSplittable: boolean;
}
function extractBody (children: React.ReactNode, isSplit: boolean): BodyState {
if (!Array.isArray(children)) {
return {
body: children,
bodySplit: null,
isEmpty: !children
isEmpty: !children,
isSplittable: false
};
}
const body = children.filter((child) => !!child);
const isEmpty = body.length === 0;
let bodySplit: [React.ReactNode[], React.ReactNode[]] | null = null;
if (!isEmpty && isSplit) {
const half = Math.ceil(body.length / 2);
bodySplit = [
body.slice(0, half),
body.slice(half)
];
}
const isSplittable = !isEmpty && isSplit;
return {
body: isEmpty || bodySplit
body: isEmpty
? null
: body,
bodySplit,
isEmpty
isEmpty,
isSplittable
};
}
function TableBase ({ children, className = '', empty, emptySpinner, filter, footer, header, headerChildren, isFixed, isInline, isSplit, legend, noBodyTag }: Props): React.ReactElement<Props> {
const { body, bodySplit, isEmpty } = useMemo(
function splitBody (body: React.ReactNode[], numColumns: number): React.ReactNode[][] {
const result = new Array<React.ReactNode[]>(numColumns);
for (let i = 0; i < numColumns; i++) {
result[i] = [];
}
for (let i = 0; i < body.length; i++) {
result[i % numColumns].push(body[i]);
}
return result;
}
function TableBase ({ children, className = '', empty, emptySpinner, filter, footer, header, headerChildren, isFixed, isInline, isSplit = false, legend, maxColumns, noBodyTag }: Props): React.ReactElement<Props> {
const { body, isEmpty, isSplittable } = useMemo(
() => extractBody(children, isSplit),
[children, isSplit]
);
const numColumns = useWindowColumns(maxColumns, isSplittable);
const bodySplit = useMemo(
() => isSplittable && (numColumns > 1) && splitBody(body as React.ReactNode[], numColumns),
[body, isSplittable, numColumns]
);
const headerNode = (
<Head
@ -85,18 +103,18 @@ function TableBase ({ children, className = '', empty, emptySpinner, filter, foo
<table className={tableClassName}>
{headerNode}
</table>
<Columar isPadded={false}>
<Columar.Column>
<table className={tableClassName}>
<Body>{bodySplit[0]}</Body>
</table>
</Columar.Column>
<Columar.Column>
<table className={tableClassName}>
<Body>{bodySplit[1]}</Body>
</table>
</Columar.Column>
</Columar>
<div className='ui--Table-Split'>
{bodySplit.map((body, index) => (
<div
className={`ui--Table-Split-Column-${numColumns}`}
key={index}
>
<table className={tableClassName}>
<Body>{body}</Body>
</table>
</div>
))}
</div>
</>
)
: (
@ -127,9 +145,19 @@ const StyledDiv = styled.div`
max-width: 100%;
width: 100%;
&.isSplit {
> .ui--Columar {
margin-bottom: 1.5rem;
.ui--Table-Split {
display: flex;
flex-wrap: wrap;
margin-bottom: 1.5rem;
> .ui--Table-Split-Column-3 {
max-width: 33.3%;
min-width: 33.3%;
}
> .ui--Table-Split-Column-2 {
max-width: 50%;
min-width: 50%;
}
}

View File

@ -78,4 +78,5 @@ export { useTreasury } from './useTreasury';
export { useTxBatch } from './useTxBatch';
export { useVotingStatus } from './useVotingStatus';
export { useWeight } from './useWeight';
export { useWindowColumns } from './useWindowColumns';
export { useWindowSize } from './useWindowSize';

View File

@ -12,12 +12,12 @@ interface Options <T> {
}
// FIXME This is generic, we cannot really use createNamedHook
export function useMapEntries <T = any> (entry: QueryableStorageEntry<'promise'> | null | false | undefined, params: unknown[], { transform }: Options<T> = {}, at?: string | null | false): T | undefined {
export function useMapEntries <T = any> (entry: QueryableStorageEntry<'promise'> | null | false | undefined, params?: unknown[] | null, { transform }: Options<T> = {}, at?: string | null | false): T | undefined {
const [state, setState] = useState<T | undefined>();
const checkRef = useRef<string | null>(null);
useEffect((): void => {
if (entry) {
if (entry && params) {
const check = stringify({ at, params });
if (check !== checkRef.current) {

View File

@ -12,12 +12,12 @@ interface Options <T> {
}
// FIXME This is generic, we cannot really use createNamedHook
export function useMapKeys <T = any> (entry: QueryableStorageEntry<'promise'> | null | false | undefined, params: unknown[], { transform }: Options<T> = {}, at?: string | null | false): T[] | undefined {
export function useMapKeys <T = any> (entry: QueryableStorageEntry<'promise'> | null | false | undefined, params?: unknown[] | null, { transform }: Options<T> = {}, at?: string | null | false): T[] | undefined {
const [state, setState] = useState<T[] | undefined>();
const checkRef = useRef<string | null>(null);
useEffect((): void => {
if (entry) {
if (entry && params) {
const check = stringify({ at, params });
if (check !== checkRef.current) {

View File

@ -0,0 +1,24 @@
// Copyright 2017-2023 @polkadot/react-hooks authors & contributors
// SPDX-License-Identifier: Apache-2.0
import { useMemo } from 'react';
import { createNamedHook } from './createNamedHook';
import { useWindowSize } from './useWindowSize';
function useWindowColumnsImpl (maxColumns: 0 | 1 | 2 | 3 = 3, isActive = true): number {
const windowSize = useWindowSize(isActive);
return useMemo(
() => isActive
? windowSize.width >= 1500
? Math.min(3, maxColumns)
: windowSize.width >= 1050
? Math.min(maxColumns, 2)
: 1
: 1,
[isActive, maxColumns, windowSize]
);
}
export const useWindowColumns = createNamedHook('useWindowColumns', useWindowColumnsImpl);

View File

@ -3,13 +3,18 @@
import type { WindowSize } from './ctx/types';
import { useContext } from 'react';
import { useContext, useRef } from 'react';
import { WindowSizeCtx } from './ctx/WindowSize';
import { createNamedHook } from './createNamedHook';
function useWindowSizeImpl (): WindowSize {
return useContext(WindowSizeCtx);
function useWindowSizeImpl (isActive = true): WindowSize {
const windowSize = useContext(WindowSizeCtx);
const staticRef = useRef(windowSize);
return isActive
? windowSize
: staticRef.current;
}
export const useWindowSize = createNamedHook('useWindowSize', useWindowSizeImpl);