feat(explorer): add headings and anchors to net params page (#4691)

This commit is contained in:
Sam Keen 2023-09-05 09:09:31 +01:00 committed by GitHub
parent 4f610bbd1b
commit 2a0811863f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 366 additions and 78 deletions

View File

@ -1 +1,2 @@
export * from './network-parameters'; export * from './network-parameters';
export * from './structure-network-params';

View File

@ -1,82 +1,105 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import type { NetworkParamsQuery } from '@vegaprotocol/network-parameters'; import type { NetworkParamsQuery } from '@vegaprotocol/network-parameters';
import { NetworkParametersTable } from './network-parameters'; import { NetworkParametersTable } from './network-parameters';
import { MemoryRouter } from 'react-router-dom';
const renderComponent = (data: NetworkParamsQuery | undefined) => {
return render(
<MemoryRouter>
<NetworkParametersTable data={data} loading={false} />
</MemoryRouter>
);
};
const mockData = {
networkParametersConnection: {
edges: [
{
node: {
key: 'spam.protection.delegation.min.tokens',
value: '3',
},
},
{
node: {
key: 'spam.protection.voting.min.tokens',
value: '1',
},
},
{
node: {
key: 'reward.staking.delegation.minimumValidatorStake',
value: '2',
},
},
{
node: {
key: 'reward.asset',
value:
'fc7fd956078fb1fc9db5c19b88f0874c4299b2a7639ad05a47a28c0aef291b55',
},
},
],
},
};
describe('NetworkParametersTable', () => { describe('NetworkParametersTable', () => {
it('renders correctly when it has network params', () => { it('renders headers correctly', () => {
const data: NetworkParamsQuery = { renderComponent(mockData);
networkParametersConnection: {
edges: [ const allHeadings = screen.getAllByRole('heading');
{ expect(
node: { allHeadings.map((h) => {
__typename: 'NetworkParameter', return {
key: 'market.liquidityProvision.minLpStakeQuantumMultiple', text: h.textContent,
value: '1', level: h.tagName,
}, testId: h.getAttribute('data-testid'),
},
{
node: {
__typename: 'NetworkParameter',
key: 'market.fee.factors.infrastructureFee',
value: '0.0005',
},
},
],
},
}; };
render(<NetworkParametersTable data={data} loading={false} />); })
expect(screen.getByTestId('network-param-header')).toHaveTextContent( ).toEqual([
'Network Parameters' {
); text: 'Network Parameters',
const rows = screen.getAllByTestId('key-value-table-row'); level: 'H1',
expect(rows[0].children[0]).toHaveTextContent( testId: 'network-param-header',
'market.fee.factors.infrastructureFee' },
); { text: 'Spam', level: 'H1', testId: 'spam' },
expect(rows[1].children[0]).toHaveTextContent( { text: 'Protection', level: 'H2', testId: 'spam-protection' },
'market.liquidityProvision.minLpStakeQuantumMultiple' { text: 'Delegation', level: 'H3', testId: 'spam-protection-delegation' },
); { text: 'Min', level: 'H4', testId: 'spam-protection-delegation-min' },
expect(rows[0].children[1]).toHaveTextContent('0.0005'); { text: 'Voting', level: 'H3', testId: 'spam-protection-voting' },
expect(rows[1].children[1]).toHaveTextContent('1'); { text: 'Min', level: 'H4', testId: 'spam-protection-voting-min' },
{ text: 'Reward', level: 'H1', testId: 'reward' },
{ text: 'Staking', level: 'H2', testId: 'reward-staking' },
{ text: 'Delegation', level: 'H3', testId: 'reward-staking-delegation' },
]);
}); });
it('renders the rows in ascending order', () => { it('renders network params correctly', () => {
const data: NetworkParamsQuery = { renderComponent(mockData);
networkParametersConnection: {
edges: [ const delegationMinTokensRow = screen.getByTestId(
{ 'spam-protection-delegation-min-tokens'
node: {
__typename: 'NetworkParameter',
key: 'market.fee.factors.infrastructureFee',
value: '0.0005',
},
},
{
node: {
__typename: 'NetworkParameter',
key: 'market.liquidityProvision.minLpStakeQuantumMultiple',
value: '1',
},
},
],
},
};
render(<NetworkParametersTable data={data} loading={false} />);
expect(screen.getByTestId('network-param-header')).toHaveTextContent(
'Network Parameters'
); );
const rows = screen.getAllByTestId('key-value-table-row'); expect(delegationMinTokensRow).toHaveTextContent('0.000000000000000003');
expect(rows[0].children[0]).toHaveTextContent(
'market.fee.factors.infrastructureFee' const votingMinTokensRow = screen.getByTestId(
'spam-protection-voting-min-tokens'
); );
expect(rows[1].children[0]).toHaveTextContent( expect(votingMinTokensRow).toHaveTextContent('0.000000000000000001');
'market.liquidityProvision.minLpStakeQuantumMultiple'
const minimumValidatorStakeRow = screen.getByTestId(
'reward-staking-delegation-minimumValidatorStake'
);
expect(minimumValidatorStakeRow).toHaveTextContent('2');
const assetRow = screen.getByTestId('reward-asset');
expect(assetRow).toHaveTextContent(
'fc7fd956078fb1fc9db5c19b88f0874c4299b2a7639ad05a47a28c0aef291b55'
); );
expect(rows[0].children[1]).toHaveTextContent('0.0005');
expect(rows[1].children[1]).toHaveTextContent('1');
}); });
it('does not render rows when is loading', () => { it('does not render rows when is loading', () => {
render(<NetworkParametersTable data={undefined} loading={true} />); renderComponent(undefined);
expect(screen.getByTestId('network-param-header')).toHaveTextContent( expect(screen.getByTestId('network-param-header')).toHaveTextContent(
'Network Parameters' 'Network Parameters'
); );

View File

@ -1,6 +1,8 @@
import startCase from 'lodash/startCase';
import classNames from 'classnames';
import { Link } from 'react-router-dom';
import { import {
AsyncRenderer, AsyncRenderer,
KeyValueTable,
KeyValueTableRow, KeyValueTableRow,
SyntaxHighlighter, SyntaxHighlighter,
} from '@vegaprotocol/ui-toolkit'; } from '@vegaprotocol/ui-toolkit';
@ -12,11 +14,12 @@ import {
} from '@vegaprotocol/utils'; } from '@vegaprotocol/utils';
import { t } from '@vegaprotocol/i18n'; import { t } from '@vegaprotocol/i18n';
import { RouteTitle } from '../../components/route-title'; import { RouteTitle } from '../../components/route-title';
import orderBy from 'lodash/orderBy';
import { useNetworkParamsQuery } from '@vegaprotocol/network-parameters'; import { useNetworkParamsQuery } from '@vegaprotocol/network-parameters';
import type { NetworkParamsQuery } from '@vegaprotocol/network-parameters';
import { useScrollToLocation } from '../../hooks/scroll-to-location'; import { useScrollToLocation } from '../../hooks/scroll-to-location';
import { useDocumentTitle } from '../../hooks/use-document-title'; import { useDocumentTitle } from '../../hooks/use-document-title';
import { structureNetworkParams } from './structure-network-params';
import type { NetworkParamsQuery } from '@vegaprotocol/network-parameters';
import type { GroupedParams } from './structure-network-params';
const PERCENTAGE_PARAMS = [ const PERCENTAGE_PARAMS = [
'governance.proposal.asset.requiredMajority', 'governance.proposal.asset.requiredMajority',
@ -58,6 +61,54 @@ const BIG_NUMBER_PARAMS = [
'governance.proposal.updateAsset.minVoterBalance', 'governance.proposal.updateAsset.minVoterBalance',
]; ];
export const renderGroupedParams = (
group: GroupedParams,
level: number,
parentKeys: string[] = []
) => {
const Header = `h${level}` as keyof JSX.IntrinsicElements;
const headerStyles = classNames('uppercase font-semibold', {
'pt-6 text-3xl underline': level === 1,
'pt-3 text-2xl': level === 2,
'pt-2 text-lg': level === 3,
'pt-2 text-default': level === 4,
});
return Object.entries(group).map(([key, value]) => {
const fullPath = [...parentKeys, key].join('.');
const isLeafNode = typeof value !== 'object';
const id = parentKeys.concat([key]).join('-');
return (
<div key={key}>
{!isLeafNode && (
<div id={id}>
<Link to={`#${id}`}>
<Header className={headerStyles} data-testid={id}>
{startCase(key)}
</Header>
</Link>
</div>
)}
{isLeafNode ? (
typeof value === 'string' ? (
<div data-testid={id}>
<NetworkParameterRow
key={fullPath}
row={{ key: fullPath, value: value }}
/>
</div>
) : null
) : (
<div className="pb-1">
{renderGroupedParams(value, level + 1, [...parentKeys, key])}
</div>
)}
</div>
);
});
};
export const NetworkParameterRow = ({ export const NetworkParameterRow = ({
row: { key, value }, row: { key, value },
}: { }: {
@ -77,7 +128,9 @@ export const NetworkParameterRow = ({
> >
{key} {key}
{isSyntaxRow ? ( {isSyntaxRow ? (
<div className="pb-2">
<SyntaxHighlighter data={JSON.parse(value)} /> <SyntaxHighlighter data={JSON.parse(value)} />
</div>
) : isNaN(Number(value)) ? ( ) : isNaN(Number(value)) ? (
value value
) : BIG_NUMBER_PARAMS.includes(key) ? ( ) : BIG_NUMBER_PARAMS.includes(key) ? (
@ -113,17 +166,12 @@ export const NetworkParametersTable = ({
loading={loading} loading={loading}
error={error} error={error}
render={(data) => { render={(data) => {
const ascParams = orderBy( const flatParams = removePaginationWrapper(
removePaginationWrapper(data.networkParametersConnection.edges), data.networkParametersConnection.edges
(param) => param.key,
'asc'
); );
const groupedParams = structureNetworkParams(flatParams);
return ( return (
<KeyValueTable data-testid="parameters"> <div className="-mt-6">{renderGroupedParams(groupedParams, 1)}</div>
{(ascParams || []).map((row) => (
<NetworkParameterRow key={row.key} row={row} />
))}
</KeyValueTable>
); );
}} }}
/> />

View File

@ -0,0 +1,109 @@
import type { GroupedParams } from './structure-network-params';
import {
structureParams,
sortGroupedParams,
structureNetworkParams,
} from './structure-network-params';
describe('structureParams', () => {
it('should correctly structure params', () => {
const input = [
{ key: 'spam.protection.delegation.min.tokens', value: '10' },
{ key: 'spam.protection.voting.min.tokens', value: '5' },
];
const output: GroupedParams = {
spam: {
protection: {
delegation: {
min: {
tokens: '10',
},
},
voting: {
min: {
tokens: '5',
},
},
},
},
};
expect(structureParams(input)).toEqual(output);
});
it('should handle top-level keys correctly', () => {
const input = [{ key: 'levelOne', value: '10' }];
const output = {
levelOne: '10',
};
expect(structureParams(input)).toEqual(output);
});
});
describe('sortGroupedParams', () => {
it('should correctly sort grouped params', () => {
const input: GroupedParams = {
spam: {
protection: {
delegation: {
min: {
tokens: '10',
},
},
},
},
reward: '50',
};
const output: GroupedParams = {
reward: '50',
spam: {
protection: {
delegation: {
min: {
tokens: '10',
},
},
},
},
};
expect(sortGroupedParams(input)).toEqual(output);
});
it('should handle already sorted keys', () => {
const input = {
a: '10',
b: {
c: '5',
d: '6',
},
};
expect(sortGroupedParams(input)).toEqual(input);
});
});
describe('structureNetworkParams', () => {
it('should structure and sort network params correctly', () => {
const input = [
{ key: 'spam.protection.delegation.min.tokens', value: '10' },
{ key: 'reward.asset', value: '50' },
];
const output: GroupedParams = {
reward: {
asset: '50',
},
spam: {
protection: {
delegation: {
min: {
tokens: '10',
},
},
},
},
};
expect(structureNetworkParams(input)).toEqual(output);
});
it('should return an empty object if no params are provided', () => {
expect(structureNetworkParams([])).toEqual({});
});
});

View File

@ -0,0 +1,107 @@
/**
* Categorizes and sorts an array of key-value pairs of network params into a nested object structure.
*
* The function takes an array of network params where keys are dot-delimited
* strings representing nested categories (e.g., 'spam.protection.delegation.min.tokens').
*
* Why this is necessary:
* A flat key-value structure wouldn't provide the hierarchical information needed.
* Organizing network parameters like this allows the rendering of nested headers
* and their corresponding network params.
*
* It also ensures that items with the minimum amount of nesting are ordered first. This
* allows us to render these items first, before more deeply nested items are rendered with
* subheaders. This creates a more intuitive UI.
*
* For example, given the input:
* [
* { key: 'spam.protection.delegation.min.tokens', value: '10' },
* { key: 'spam.protection.voting.min.tokens', value: '5' },
* { key: 'reward.staking.delegation.minimumValidatorStake', value: '2' }
* { key: 'reward.asset', value: 'fc7fd956078fb1fc9db5c19b88f0874c4299b2a7639ad05a47a28c0aef291b55' }
* ]
*
* The output will be:
* {
* spam: {
* protection: {
* delegation: {
* min: {
* tokens: '10'
* }
* },
* voting: {
* min: {
* tokens: '5'
* }
* }
* }
* },
* reward: {
* asset: 'fc7fd956078fb1fc9db5c19b88f0874c4299b2a7639ad05a47a28c0aef291b55',
* staking: {
* delegation: {
* minimumValidatorStake: '2'
* }
* }
* }
* }
*
* @param {Array} params - An array of key-value pairs to categorize and sort.
* @returns {GroupedParams} - A nested object that groups the key-value pairs.
*/
export type GroupedParams = {
[key: string]: string | GroupedParams;
};
export const structureParams = (
params: { key: string; value: string }[]
): GroupedParams => {
const grouped: GroupedParams = {};
params.forEach(({ key, value }) => {
const parts = key.split('.');
let node: GroupedParams = grouped;
parts.forEach((part, i) => {
if (typeof node[part] === 'undefined') {
node[part] = i === parts.length - 1 ? value : {};
}
if (typeof node[part] === 'object') {
node = node[part] as GroupedParams;
}
});
});
return grouped;
};
export const sortGroupedParams = (
groupedParams: GroupedParams
): GroupedParams => {
const sorted: GroupedParams = {};
// Sort top-level keys first
Object.entries(groupedParams).forEach(([key, value]) => {
if (typeof value === 'string') {
sorted[key] = value;
}
});
Object.entries(groupedParams).forEach(([key, value]) => {
if (typeof value === 'object') {
sorted[key] = sortGroupedParams(value);
}
});
return sorted;
};
export const structureNetworkParams = (
params: { key: string; value: string }[]
) => {
const grouped = structureParams(params);
return sortGroupedParams(grouped);
};