From f3ebf827c75c2b002be13e7121773caeb387b391 Mon Sep 17 00:00:00 2001 From: Sam Calder-Mason Date: Tue, 20 Oct 2026 17:54:38 +1000 Subject: [PATCH] feat(network-select): group networks into sections with devnet family subheaders Devnet names like glamsterdam-devnet-8 truncated in the sidebar selector and wrapped on the home page cards. Networks named -devnet- or -shadowfork- are now parsed into a family and a short label, and the selector lists them under a heading per family (Public first, then devnet families newest first, then anything unrecognised under Other). The selected devnet shows its family above its label in the button, and the home page uses the same grouping with a heading per devnet family. --- .storybook/mocks.ts | 24 ++++ .../DataDisplay/Timestamp/TimestampModal.tsx | 3 +- .../NetworkSelect/NetworkSelect.stories.tsx | 16 +++ .../Ethereum/NetworkSelect/NetworkSelect.tsx | 134 +++++++++++------- .../Layout/PopoutCard/PopoutCard.tsx | 3 +- src/components/Layout/Sidebar/Sidebar.tsx | 3 +- .../Overlays/FeatureGate/FeatureGate.tsx | 8 +- .../components/HeroSection/HeroSection.tsx | 41 ++---- src/utils/networks.test.ts | 81 +++++++++++ src/utils/networks.ts | 95 +++++++++++++ 10 files changed, 323 insertions(+), 85 deletions(-) create mode 100644 src/utils/networks.test.ts diff --git a/.storybook/mocks.ts b/.storybook/mocks.ts index c18965d89..3a32cf12d 100644 --- a/.storybook/mocks.ts +++ b/.storybook/mocks.ts @@ -130,6 +130,30 @@ export const mockConfig: Config = { }, }, }, + { + name: 'glamsterdam-devnet-7', + display_name: 'Glamsterdam-devnet-7', + chain_id: 7091047533, + genesis_time: 1784808000, + genesis_delay: 60, + forks: { + consensus: { + gloas: { epoch: 0, min_client_versions: {} }, + }, + }, + }, + { + name: 'glamsterdam-devnet-8', + display_name: 'Glamsterdam-devnet-8', + chain_id: 7091047534, + genesis_time: 1786622400, + genesis_delay: 60, + forks: { + consensus: { + gloas: { epoch: 0, min_client_versions: {} }, + }, + }, + }, ], features: [], }; diff --git a/src/components/DataDisplay/Timestamp/TimestampModal.tsx b/src/components/DataDisplay/Timestamp/TimestampModal.tsx index 7f6dfe303..c4208c97b 100644 --- a/src/components/DataDisplay/Timestamp/TimestampModal.tsx +++ b/src/components/DataDisplay/Timestamp/TimestampModal.tsx @@ -12,6 +12,7 @@ import { slotToEpoch, epochToTimestamp, SECONDS_PER_SLOT, SLOTS_PER_EPOCH } from import { getAllDiscordFormats, DISCORD_STYLE_LABELS } from '@/utils/discord-timestamp'; import { NetworkIcon } from '@/components/Ethereum/NetworkIcon'; import { formatSlot, formatEpoch } from '@/utils/number'; +import { getNetworkFullLabel } from '@/utils/networks'; import { Slot } from '@/components/Ethereum/Slot'; import { Epoch } from '@/components/Ethereum/Epoch'; import type { DiscordTimestampStyle } from '@/utils/discord-timestamp'; @@ -179,7 +180,7 @@ function TimestampModalContent({ timestamp }: TimestampModalContentProps): JSX.E
- {currentNetwork.display_name} + {getNetworkFullLabel(currentNetwork)}
diff --git a/src/components/Ethereum/NetworkSelect/NetworkSelect.stories.tsx b/src/components/Ethereum/NetworkSelect/NetworkSelect.stories.tsx index f0702470a..ba26916e7 100644 --- a/src/components/Ethereum/NetworkSelect/NetworkSelect.stories.tsx +++ b/src/components/Ethereum/NetworkSelect/NetworkSelect.stories.tsx @@ -66,6 +66,22 @@ export const WithHoleskySelected: Story = { }, }; +/** + * Network selector with a devnet pre-selected. The button shows the devnet family above the + * devnet label, and the menu lists devnets under their family heading. + */ +export const WithDevnetSelected: Story = { + args: { + showLabel: false, + }, + parameters: { + router: { + initialUrl: '/', + initialSearch: { network: 'glamsterdam-devnet-8' }, + }, + }, +}; + /** * Network selector with Sepolia pre-selected via URL params. */ diff --git a/src/components/Ethereum/NetworkSelect/NetworkSelect.tsx b/src/components/Ethereum/NetworkSelect/NetworkSelect.tsx index 0498c4d71..3fab5c51a 100644 --- a/src/components/Ethereum/NetworkSelect/NetworkSelect.tsx +++ b/src/components/Ethereum/NetworkSelect/NetworkSelect.tsx @@ -1,49 +1,24 @@ import { type JSX, useMemo } from 'react'; -import { useNetwork, type Network } from '@/hooks/useNetwork'; -import { SelectMenu, type SelectMenuOption } from '@/components/Forms/SelectMenu'; +import { clsx } from 'clsx'; +import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'; +import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/16/solid'; +import { useNetwork } from '@/hooks/useNetwork'; import { NetworkIcon } from '@/components/Ethereum/NetworkIcon'; -import { NETWORK_ORDER } from '@/utils/networks'; +import { getNetworkFullLabel, getNetworkShortLabel, groupNetworks, parseNetworkName } from '@/utils/networks'; import type { NetworkSelectProps } from './NetworkSelect.types'; -/** - * Sorts networks by the predefined order, then alphabetically for any remaining networks. - */ -function sortNetworks(networks: Network[]): Network[] { - return [...networks].sort((a, b) => { - const aIndex = NETWORK_ORDER.indexOf(a.name); - const bIndex = NETWORK_ORDER.indexOf(b.name); - - // Both in the priority list - sort by their position - if (aIndex !== -1 && bIndex !== -1) { - return aIndex - bIndex; - } - - // Only a is in the priority list - a comes first - if (aIndex !== -1) { - return -1; - } - - // Only b is in the priority list - b comes first - if (bIndex !== -1) { - return 1; - } - - // Neither in priority list - sort alphabetically by name - return a.name.localeCompare(b.name); - }); -} - /** * Network selector dropdown component. * - * Displays a list of available networks from the config and allows switching between them. - * The selected network is persisted in localStorage and accessible via the useNetwork hook. + * Displays the available networks from the config, grouped into sections, and allows switching + * between them. The selected network is persisted in localStorage and accessible via the + * useNetwork hook. * * Features: - * - Defaults to "mainnet" if available, otherwise the first network - * - Shows network display name with NetworkIcon component + * - Public networks first (mainnet, holesky, sepolia, hoodi), then one section per devnet family + * - Devnets are labelled within their family ("Devnet 8" under "Glamsterdam") so long names never truncate + * - The selected devnet shows its family above its label in the button * - Highlights currently selected network with a checkmark - * - Networks ordered by priority: mainnet, holesky, sepolia, hoodi, then alphabetically * - Keyboard accessible * - Dark mode support * @@ -66,29 +41,80 @@ export function NetworkSelect({ }: NetworkSelectProps): JSX.Element { const { currentNetwork, setCurrentNetwork, networks } = useNetwork(); - const options: SelectMenuOption[] = useMemo( - () => - sortNetworks(networks).map((network: Network) => ({ - value: network, - label: network.display_name, - icon: , - })), - [networks] - ); + const groups = useMemo(() => groupNetworks(networks), [networks]); if (!currentNetwork) { return <>; } + const family = parseNetworkName(currentNetwork.name)?.family; + return ( - + + {showLabel && } +
+ + + + + + {family && ( + {family} + )} + {getNetworkShortLabel(currentNetwork)} + + + + + + + {groups.map((group, index) => ( +
0 && 'mt-1 border-t border-border/50')} + > + + {group.networks.map(network => ( + + + + + {getNetworkShortLabel(network)} + + + + ))} +
+ ))} +
+
+
); } diff --git a/src/components/Layout/PopoutCard/PopoutCard.tsx b/src/components/Layout/PopoutCard/PopoutCard.tsx index 5f5fbb512..8b4ada22d 100644 --- a/src/components/Layout/PopoutCard/PopoutCard.tsx +++ b/src/components/Layout/PopoutCard/PopoutCard.tsx @@ -14,6 +14,7 @@ import { CopyToClipboard } from '@/components/Elements/CopyToClipboard'; import { NetworkIcon } from '@/components/Ethereum/NetworkIcon'; import { useChartDownload } from '@/hooks/useChartDownload'; import { useNetwork } from '@/hooks/useNetwork'; +import { getNetworkFullLabel } from '@/utils/networks'; import type { PopoutCardProps } from './PopoutCard.types'; /** @@ -123,7 +124,7 @@ export function PopoutCard({ // Metadata footer for downloads (only shown during screenshot capture) const metadataTitle = downloadMetadataTitle || document.title; const metadataSubtitle = downloadMetadataSubtitle || window.location.href; - const metadataNetwork = downloadMetadataNetwork || currentNetwork?.display_name || ''; + const metadataNetwork = downloadMetadataNetwork || (currentNetwork ? getNetworkFullLabel(currentNetwork) : ''); const metadataFooter = appendMetadataToDownload && showMetadata ? (
+
) diff --git a/src/components/Overlays/FeatureGate/FeatureGate.tsx b/src/components/Overlays/FeatureGate/FeatureGate.tsx index 27de00b4f..65f7844b7 100644 --- a/src/components/Overlays/FeatureGate/FeatureGate.tsx +++ b/src/components/Overlays/FeatureGate/FeatureGate.tsx @@ -3,6 +3,7 @@ import { useLocation, Link } from '@tanstack/react-router'; import { useIsPageEnabled } from '@/hooks/useIsPageEnabled'; import { useNetwork } from '@/hooks/useNetwork'; import { useConfig } from '@/hooks/useConfig'; +import { getNetworkFullLabel } from '@/utils/networks'; interface FeatureGateProps { children: React.ReactNode; @@ -47,7 +48,10 @@ export function FeatureGate({ children }: FeatureGateProps): JSX.Element {

Feature Not Available

This feature is not available on{' '} - {currentNetwork?.display_name}. + + {currentNetwork && getNetworkFullLabel(currentNetwork)} + + .

{availableNetworks.length > 0 && ( @@ -61,7 +65,7 @@ export function FeatureGate({ children }: FeatureGateProps): JSX.Element { search={{ network: network.name }} className="font-medium text-primary hover:underline" > - {network.display_name} + {getNetworkFullLabel(network)} ))} diff --git a/src/pages/home/components/HeroSection/HeroSection.tsx b/src/pages/home/components/HeroSection/HeroSection.tsx index 494ef8ee7..f66e81aef 100644 --- a/src/pages/home/components/HeroSection/HeroSection.tsx +++ b/src/pages/home/components/HeroSection/HeroSection.tsx @@ -1,11 +1,11 @@ -import { type JSX, useEffect, useMemo, useRef, useState } from 'react'; +import { type JSX, Fragment, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from '@tanstack/react-router'; import { NetworkIcon } from '@/components/Ethereum/NetworkIcon'; import { type Network } from '@/hooks/useConfig/useConfig.types'; import { useNetwork } from '@/hooks/useNetwork'; import { SECONDS_PER_SLOT, SLOTS_PER_EPOCH } from '@/utils/beacon'; import { getActiveFork } from '@/utils/forks'; -import { NETWORK_ORDER } from '@/utils/networks'; +import { getNetworkShortLabel, groupNetworks } from '@/utils/networks'; import { formatSlot } from '@/utils/number'; /** Compute the current slot for a network from its genesis time. */ @@ -88,7 +88,7 @@ function NetworkCard({
- {network.display_name} + {getNetworkShortLabel(network)}
{forkLabel} @@ -117,13 +117,7 @@ export function HeroSection(): JSX.Element { const { networks, setCurrentNetwork } = useNetwork(); const now = useNow(); - const { staticNets, devnets } = useMemo(() => { - const s = networks - .filter(n => NETWORK_ORDER.includes(n.name)) - .sort((a, b) => NETWORK_ORDER.indexOf(a.name) - NETWORK_ORDER.indexOf(b.name)); - const d = networks.filter(n => !NETWORK_ORDER.includes(n.name)); - return { staticNets: s, devnets: d }; - }, [networks]); + const groups = useMemo(() => groupNetworks(networks), [networks]); return (
@@ -157,27 +151,22 @@ export function HeroSection(): JSX.Element {
{/* Network instrument panels */} - {(staticNets.length > 0 || devnets.length > 0) && ( + {groups.length > 0 && (
- {staticNets.length > 0 && ( -
- {staticNets.map(network => ( - - ))} -
- )} - {devnets.length > 0 && ( - <> - - Devnets - + {groups.map(group => ( + + {group.id !== 'public' && ( + + {group.id === 'other' ? 'Devnets' : `${group.label} devnets`} + + )}
- {devnets.map(network => ( + {group.networks.map(network => ( ))}
- - )} +
+ ))}
)}
diff --git a/src/utils/networks.test.ts b/src/utils/networks.test.ts new file mode 100644 index 000000000..53710703a --- /dev/null +++ b/src/utils/networks.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { getNetworkFullLabel, getNetworkShortLabel, groupNetworks, parseNetworkName } from './networks'; +import type { Network } from '@/hooks/useConfig'; + +function network(name: string, genesisTime = 0, displayName = name.charAt(0).toUpperCase() + name.slice(1)): Network { + return { + name, + display_name: displayName, + chain_id: 1, + genesis_time: genesisTime, + genesis_delay: 0, + forks: { consensus: {} }, + }; +} + +describe('networks utilities', () => { + describe('parseNetworkName', () => { + it('should split devnet names into family and label', () => { + expect(parseNetworkName('glamsterdam-devnet-8')).toEqual({ family: 'Glamsterdam', label: 'Devnet 8' }); + }); + + it('should handle multi-word families and shadowforks', () => { + expect(parseNetworkName('fusaka-mainnet-shadowfork-0')).toEqual({ + family: 'Fusaka Mainnet', + label: 'Shadowfork 0', + }); + }); + + it('should return null for persistent and unrecognised names', () => { + expect(parseNetworkName('mainnet')).toBeNull(); + expect(parseNetworkName('hoodi')).toBeNull(); + expect(parseNetworkName('devnet-8')).toBeNull(); + expect(parseNetworkName('glamsterdam-devnet-8a')).toBeNull(); + }); + }); + + describe('labels', () => { + it('should shorten devnet labels and keep display names otherwise', () => { + expect(getNetworkShortLabel(network('glamsterdam-devnet-8'))).toBe('Devnet 8'); + expect(getNetworkShortLabel(network('mainnet'))).toBe('Mainnet'); + }); + + it('should build full devnet labels and keep display names otherwise', () => { + expect(getNetworkFullLabel(network('glamsterdam-devnet-8'))).toBe('Glamsterdam Devnet 8'); + expect(getNetworkFullLabel(network('sepolia'))).toBe('Sepolia'); + expect(getNetworkFullLabel(network('perf-net', 0, 'Perf Net'))).toBe('Perf Net'); + }); + }); + + describe('groupNetworks', () => { + it('should order public networks canonically, then devnet families, then the rest', () => { + const groups = groupNetworks([ + network('glamsterdam-devnet-7', 100), + network('perf-net'), + network('hoodi'), + network('glamsterdam-devnet-8', 200), + network('mainnet'), + network('bal-devnet-2', 50), + network('sepolia'), + ]); + + expect(groups.map(group => [group.label, group.networks.map(n => n.name)])).toEqual([ + ['Public', ['mainnet', 'sepolia', 'hoodi']], + ['Glamsterdam', ['glamsterdam-devnet-8', 'glamsterdam-devnet-7']], + ['Bal', ['bal-devnet-2']], + ['Other', ['perf-net']], + ]); + }); + + it('should order devnets by genesis rather than alphabetically', () => { + const groups = groupNetworks([network('fusaka-devnet-9', 900), network('fusaka-devnet-10', 1000)]); + + expect(groups[0].networks.map(n => n.name)).toEqual(['fusaka-devnet-10', 'fusaka-devnet-9']); + }); + + it('should omit empty groups', () => { + expect(groupNetworks([network('mainnet')]).map(group => group.id)).toEqual(['public']); + expect(groupNetworks([])).toEqual([]); + }); + }); +}); diff --git a/src/utils/networks.ts b/src/utils/networks.ts index 2cba05162..80e21cb56 100644 --- a/src/utils/networks.ts +++ b/src/utils/networks.ts @@ -1,2 +1,97 @@ +import type { Network } from '@/hooks/useConfig'; + /** Canonical ordering of well-known persistent networks (mainnet + testnets). */ export const NETWORK_ORDER: readonly string[] = ['mainnet', 'holesky', 'sepolia', 'hoodi']; + +/** Short-lived network name split into its family and its label within that family. */ +export interface ParsedNetworkName { + /** Family the network belongs to, e.g. "Glamsterdam" */ + family: string; + /** Label within the family, e.g. "Devnet 8" */ + label: string; +} + +/** Networks shown together under one heading. */ +export interface NetworkGroup { + /** Stable key, e.g. "public", "family:Glamsterdam", "other" */ + id: string; + /** Heading, e.g. "Public" or "Glamsterdam" */ + label: string; + networks: Network[]; +} + +const EPHEMERAL_NETWORK_PATTERN = /^([a-z0-9-]+)-(devnet|shadowfork)-(\d+)$/; + +function titleCase(value: string): string { + return value + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); +} + +function newestFirst(a: Network, b: Network): number { + return b.genesis_time - a.genesis_time || a.name.localeCompare(b.name); +} + +/** + * Parses ethPandaOps devnet and shadowfork names such as `glamsterdam-devnet-8` + * or `fusaka-mainnet-shadowfork-0`. Returns null for any other name. + */ +export function parseNetworkName(name: string): ParsedNetworkName | null { + const match = EPHEMERAL_NETWORK_PATTERN.exec(name); + if (!match) { + return null; + } + + const [, family, kind, index] = match; + return { family: titleCase(family), label: `${titleCase(kind)} ${index}` }; +} + +/** Label for a network within its group: "Devnet 8" for `glamsterdam-devnet-8`, otherwise its display name. */ +export function getNetworkShortLabel(network: Network): string { + return parseNetworkName(network.name)?.label ?? network.display_name; +} + +/** Standalone label for a network: "Glamsterdam Devnet 8" for `glamsterdam-devnet-8`, otherwise its display name. */ +export function getNetworkFullLabel(network: Network): string { + const parsed = parseNetworkName(network.name); + return parsed ? `${parsed.family} ${parsed.label}` : network.display_name; +} + +/** + * Groups networks for display: public networks in canonical order, then one group per + * devnet family (newest family first, newest network first), then anything unrecognised. + * Empty groups are omitted. + */ +export function groupNetworks(networks: Network[]): NetworkGroup[] { + const publicNetworks = networks + .filter(network => NETWORK_ORDER.includes(network.name)) + .sort((a, b) => NETWORK_ORDER.indexOf(a.name) - NETWORK_ORDER.indexOf(b.name)); + + const families = new Map(); + const other: Network[] = []; + + for (const network of networks) { + if (NETWORK_ORDER.includes(network.name)) { + continue; + } + + const parsed = parseNetworkName(network.name); + if (!parsed) { + other.push(network); + continue; + } + + families.set(parsed.family, [...(families.get(parsed.family) ?? []), network]); + } + + const familyGroups: NetworkGroup[] = [...families] + .map(([family, members]) => ({ id: `family:${family}`, label: family, networks: members.sort(newestFirst) })) + .sort((a, b) => newestFirst(a.networks[0], b.networks[0])); + + return [ + { id: 'public', label: 'Public', networks: publicNetworks }, + ...familyGroups, + { id: 'other', label: 'Other', networks: other.sort((a, b) => a.name.localeCompare(b.name)) }, + ].filter(group => group.networks.length > 0); +}