diff --git a/apps/explorer/src/app/routes/network-parameters/index.tsx b/apps/explorer/src/app/routes/network-parameters/index.tsx index 90c3f470a..097b457e6 100644 --- a/apps/explorer/src/app/routes/network-parameters/index.tsx +++ b/apps/explorer/src/app/routes/network-parameters/index.tsx @@ -1 +1,2 @@ export * from './network-parameters'; +export * from './structure-network-params'; diff --git a/apps/explorer/src/app/routes/network-parameters/network-parameters.spec.tsx b/apps/explorer/src/app/routes/network-parameters/network-parameters.spec.tsx index 933f1224d..901c5f22d 100644 --- a/apps/explorer/src/app/routes/network-parameters/network-parameters.spec.tsx +++ b/apps/explorer/src/app/routes/network-parameters/network-parameters.spec.tsx @@ -1,82 +1,105 @@ import { render, screen } from '@testing-library/react'; import type { NetworkParamsQuery } from '@vegaprotocol/network-parameters'; import { NetworkParametersTable } from './network-parameters'; +import { MemoryRouter } from 'react-router-dom'; + +const renderComponent = (data: NetworkParamsQuery | undefined) => { + return render( + + + + ); +}; + +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', () => { - it('renders correctly when it has network params', () => { - const data: NetworkParamsQuery = { - networkParametersConnection: { - edges: [ - { - node: { - __typename: 'NetworkParameter', - key: 'market.liquidityProvision.minLpStakeQuantumMultiple', - value: '1', - }, - }, - { - node: { - __typename: 'NetworkParameter', - key: 'market.fee.factors.infrastructureFee', - value: '0.0005', - }, - }, - ], + it('renders headers correctly', () => { + renderComponent(mockData); + + const allHeadings = screen.getAllByRole('heading'); + expect( + allHeadings.map((h) => { + return { + text: h.textContent, + level: h.tagName, + testId: h.getAttribute('data-testid'), + }; + }) + ).toEqual([ + { + text: 'Network Parameters', + level: 'H1', + testId: 'network-param-header', }, - }; - render(); - expect(screen.getByTestId('network-param-header')).toHaveTextContent( - 'Network Parameters' - ); - const rows = screen.getAllByTestId('key-value-table-row'); - expect(rows[0].children[0]).toHaveTextContent( - 'market.fee.factors.infrastructureFee' - ); - expect(rows[1].children[0]).toHaveTextContent( - 'market.liquidityProvision.minLpStakeQuantumMultiple' - ); - expect(rows[0].children[1]).toHaveTextContent('0.0005'); - expect(rows[1].children[1]).toHaveTextContent('1'); + { text: 'Spam', level: 'H1', testId: 'spam' }, + { text: 'Protection', level: 'H2', testId: 'spam-protection' }, + { text: 'Delegation', level: 'H3', testId: 'spam-protection-delegation' }, + { text: 'Min', level: 'H4', testId: 'spam-protection-delegation-min' }, + { text: 'Voting', level: 'H3', testId: 'spam-protection-voting' }, + { 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', () => { - const data: NetworkParamsQuery = { - networkParametersConnection: { - edges: [ - { - node: { - __typename: 'NetworkParameter', - key: 'market.fee.factors.infrastructureFee', - value: '0.0005', - }, - }, - { - node: { - __typename: 'NetworkParameter', - key: 'market.liquidityProvision.minLpStakeQuantumMultiple', - value: '1', - }, - }, - ], - }, - }; - render(); - expect(screen.getByTestId('network-param-header')).toHaveTextContent( - 'Network Parameters' + it('renders network params correctly', () => { + renderComponent(mockData); + + const delegationMinTokensRow = screen.getByTestId( + 'spam-protection-delegation-min-tokens' ); - const rows = screen.getAllByTestId('key-value-table-row'); - expect(rows[0].children[0]).toHaveTextContent( - 'market.fee.factors.infrastructureFee' + expect(delegationMinTokensRow).toHaveTextContent('0.000000000000000003'); + + const votingMinTokensRow = screen.getByTestId( + 'spam-protection-voting-min-tokens' ); - expect(rows[1].children[0]).toHaveTextContent( - 'market.liquidityProvision.minLpStakeQuantumMultiple' + expect(votingMinTokensRow).toHaveTextContent('0.000000000000000001'); + + 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', () => { - render(); + renderComponent(undefined); expect(screen.getByTestId('network-param-header')).toHaveTextContent( 'Network Parameters' ); diff --git a/apps/explorer/src/app/routes/network-parameters/network-parameters.tsx b/apps/explorer/src/app/routes/network-parameters/network-parameters.tsx index 8eb9522a0..71d99d2c2 100644 --- a/apps/explorer/src/app/routes/network-parameters/network-parameters.tsx +++ b/apps/explorer/src/app/routes/network-parameters/network-parameters.tsx @@ -1,6 +1,8 @@ +import startCase from 'lodash/startCase'; +import classNames from 'classnames'; +import { Link } from 'react-router-dom'; import { AsyncRenderer, - KeyValueTable, KeyValueTableRow, SyntaxHighlighter, } from '@vegaprotocol/ui-toolkit'; @@ -12,11 +14,12 @@ import { } from '@vegaprotocol/utils'; import { t } from '@vegaprotocol/i18n'; import { RouteTitle } from '../../components/route-title'; -import orderBy from 'lodash/orderBy'; import { useNetworkParamsQuery } from '@vegaprotocol/network-parameters'; -import type { NetworkParamsQuery } from '@vegaprotocol/network-parameters'; import { useScrollToLocation } from '../../hooks/scroll-to-location'; 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 = [ 'governance.proposal.asset.requiredMajority', @@ -58,6 +61,54 @@ const BIG_NUMBER_PARAMS = [ '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 ( +
+ {!isLeafNode && ( +
+ +
+ {startCase(key)} +
+ +
+ )} + {isLeafNode ? ( + typeof value === 'string' ? ( +
+ +
+ ) : null + ) : ( +
+ {renderGroupedParams(value, level + 1, [...parentKeys, key])} +
+ )} +
+ ); + }); +}; + export const NetworkParameterRow = ({ row: { key, value }, }: { @@ -77,7 +128,9 @@ export const NetworkParameterRow = ({ > {key} {isSyntaxRow ? ( - +
+ +
) : isNaN(Number(value)) ? ( value ) : BIG_NUMBER_PARAMS.includes(key) ? ( @@ -113,17 +166,12 @@ export const NetworkParametersTable = ({ loading={loading} error={error} render={(data) => { - const ascParams = orderBy( - removePaginationWrapper(data.networkParametersConnection.edges), - (param) => param.key, - 'asc' + const flatParams = removePaginationWrapper( + data.networkParametersConnection.edges ); + const groupedParams = structureNetworkParams(flatParams); return ( - - {(ascParams || []).map((row) => ( - - ))} - +
{renderGroupedParams(groupedParams, 1)}
); }} /> diff --git a/apps/explorer/src/app/routes/network-parameters/structure-network-params.spec.tsx b/apps/explorer/src/app/routes/network-parameters/structure-network-params.spec.tsx new file mode 100644 index 000000000..34f2010a5 --- /dev/null +++ b/apps/explorer/src/app/routes/network-parameters/structure-network-params.spec.tsx @@ -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({}); + }); +}); diff --git a/apps/explorer/src/app/routes/network-parameters/structure-network-params.tsx b/apps/explorer/src/app/routes/network-parameters/structure-network-params.tsx new file mode 100644 index 000000000..f59adeb35 --- /dev/null +++ b/apps/explorer/src/app/routes/network-parameters/structure-network-params.tsx @@ -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); +};