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 && (
+
+
+
+
+
+ )}
+ {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);
+};