diff --git a/.changelog/1242.feature.md b/.changelog/1242.feature.md new file mode 100644 index 0000000000..d8b6cc7a2b --- /dev/null +++ b/.changelog/1242.feature.md @@ -0,0 +1 @@ +Support limiting scope to one network or layer diff --git a/.env b/.env index 4c551766eb..57ada427b6 100644 --- a/.env +++ b/.env @@ -23,3 +23,5 @@ REACT_APP_SOCIAL_REDDIT=https://www.reddit.com/r/oasisnetwork/ REACT_APP_PRODUCTION_URLS=https://explorer.oasis.io, https://explorer.prd.oasis.io REACT_APP_STAGING_URLS=https://explorer.stg.oasis.io REACT_APP_SHOW_BUILD_BANNERS=true +# REACT_APP_FIXED_NETWORK=testnet +# REACT_APP_FIXED_LAYER=sapphire diff --git a/.env.production b/.env.production index f40ed05247..99b926670c 100644 --- a/.env.production +++ b/.env.production @@ -18,3 +18,5 @@ REACT_APP_SOCIAL_REDDIT=https://www.reddit.com/r/oasisnetwork/ REACT_APP_PRODUCTION_URLS=https://explorer.oasis.io, https://explorer.prd.oasis.io REACT_APP_STAGING_URLS=https://explorer.stg.oasis.io REACT_APP_SHOW_BUILD_BANNERS=true +# REACT_APP_FIXED_NETWORK=testnet +# REACT_APP_FIXED_LAYER=sapphire diff --git a/src/app/components/LayerPicker/LayerMenu.tsx b/src/app/components/LayerPicker/LayerMenu.tsx index ec0dca8854..710ffd24bd 100644 --- a/src/app/components/LayerPicker/LayerMenu.tsx +++ b/src/app/components/LayerPicker/LayerMenu.tsx @@ -13,6 +13,7 @@ import { RouteUtils } from '../../utils/route-utils' import { Network } from '../../../types/network' import { isLayerHidden, orderByLayer } from '../../../types/layers' import { useScreenSize } from '../../hooks/useScreensize' +import { useScopeParam } from '../../hooks/useScopeParam' type BaseLayerMenuItemProps = { divider: boolean @@ -107,9 +108,11 @@ export const LayerMenu: FC = ({ selectedNetwork, setSelectedLayer, }) => { + const currentScope = useScopeParam() const [hoveredLayer, setHoveredLayer] = useState() const options = Object.values(Layer) - .filter(layer => !isLayerHidden(layer)) + // Don't show hidden layers, unless we are already viewing them. + .filter(layer => !isLayerHidden(layer) || layer === currentScope?.layer) .map(layer => ({ layer, enabled: RouteUtils.getAllLayersForNetwork(selectedNetwork || network).enabled.includes(layer), diff --git a/src/app/components/LayerPicker/index.tsx b/src/app/components/LayerPicker/index.tsx index 4b461100e7..f642af262d 100644 --- a/src/app/components/LayerPicker/index.tsx +++ b/src/app/components/LayerPicker/index.tsx @@ -13,7 +13,7 @@ import { useRequiredScopeParam } from '../../hooks/useScopeParam' import { NetworkMenu } from './NetworkMenu' import { LayerMenu } from './LayerMenu' import { LayerDetails } from './LayerDetails' -import { RouteUtils } from '../../utils/route-utils' +import { scopeFreedom, RouteUtils } from '../../utils/route-utils' import { styled } from '@mui/material/styles' import KeyboardArrowLeft from '@mui/icons-material/KeyboardArrowLeft' import { useScreenSize } from '../../hooks/useScreensize' @@ -104,28 +104,33 @@ const LayerPickerContent: FC = ({ isOutOfDate, onClose, {isTablet && (
- {tabletStep === LayerPickerTabletStep.Layer && ( - } - onClick={() => { - setTabletStep(LayerPickerTabletStep.Network) - }} - > - {t('layerPicker.viewNetworks')} - - )} - {tabletStep === LayerPickerTabletStep.LayerDetails && ( - } - onClick={() => { - setTabletStep(LayerPickerTabletStep.Layer) - }} - > - {t('layerPicker.viewLayers')} - - )} + { + // Do we need a "back to networks" button ? + ((scopeFreedom === 'network-layer' && tabletStep === LayerPickerTabletStep.Layer) || // Stepping back from layers + (scopeFreedom === 'network' && tabletStep === LayerPickerTabletStep.LayerDetails)) && ( // Stepping back from details, skipping layers + } + onClick={() => { + setTabletStep(LayerPickerTabletStep.Network) + }} + > + {t('layerPicker.viewNetworks')} + + ) + } + {scopeFreedom !== 'network' && + tabletStep === LayerPickerTabletStep.LayerDetails && ( // Stepping back from details, going to layers + } + onClick={() => { + setTabletStep(LayerPickerTabletStep.Layer) + }} + > + {t('layerPicker.viewLayers')} + + )}
@@ -133,32 +138,38 @@ const LayerPickerContent: FC = ({ isOutOfDate, onClose, - {(!isTablet || (isTablet && tabletStep === LayerPickerTabletStep.Network)) && ( - - { - selectNetwork(network) - setTabletStep(LayerPickerTabletStep.Layer) - }} - /> - - )} - {(!isTablet || (isTablet && tabletStep === LayerPickerTabletStep.Layer)) && ( - - { - setSelectedLayer(layer) - setTabletStep(LayerPickerTabletStep.LayerDetails) - }} - /> - - )} + {scopeFreedom !== 'layer' && + (!isTablet || (isTablet && tabletStep === LayerPickerTabletStep.Network)) && ( + + { + selectNetwork(network) + setTabletStep( + scopeFreedom === 'network' // Are we fixed to a specific layer, selecting only network? + ? LayerPickerTabletStep.LayerDetails // If so, skip layer selection, go straight to layer details. + : LayerPickerTabletStep.Layer, // Otherwise, go to layer selection. + ) + }} + /> + + )} + {scopeFreedom !== 'network' && + (!isTablet || (isTablet && tabletStep === LayerPickerTabletStep.Layer)) && ( + + { + setSelectedLayer(layer) + setTabletStep(LayerPickerTabletStep.LayerDetails) + }} + /> + + )} {(!isTablet || (isTablet && tabletStep === LayerPickerTabletStep.LayerDetails)) && ( { const theme = useTheme() const { isMobile } = useScreenSize() const scope = useScopeParam() + const withScopeSelector = !!scope && isScopeSelectorNeeded(scope) const scrollTrigger = useScrollTrigger({ disableHysteresis: true, threshold: 0, @@ -49,7 +51,7 @@ export const Header: FC = () => { showText={!scrollTrigger && !isMobile} /> - {scope && ( + {withScopeSelector && ( <> diff --git a/src/app/components/PageLayout/NetworkButton.tsx b/src/app/components/PageLayout/NetworkButton.tsx index 79dc638293..e354054167 100644 --- a/src/app/components/PageLayout/NetworkButton.tsx +++ b/src/app/components/PageLayout/NetworkButton.tsx @@ -5,10 +5,12 @@ import Button, { buttonClasses } from '@mui/material/Button' import EditIcon from '@mui/icons-material/Edit' import { styled } from '@mui/material/styles' import { COLORS } from '../../../styles/theme/colors' -import { Network } from '../../../types/network' +import { getNetworkNames, Network } from '../../../types/network' import { Layer } from '../../../oasis-nexus/api' import { getLayerLabels, getNetworkIcons } from '../../utils/content' import { LayerStatus } from '../LayerStatus' +import { fixedLayer } from '../../utils/route-utils' +import { TFunction } from 'i18next' export const StyledNetworkButton = styled(Button)(({ theme }) => ({ alignItems: 'center', @@ -75,9 +77,13 @@ type NetworkButtonProps = { onClick: () => void } +const getNetworkButtonLabel = (t: TFunction, network: Network, layer: Layer) => + fixedLayer // If we are fixed to a layer, + ? getNetworkNames(t)[network] // let's show the name of the network, + : getLayerLabels(t)[layer] // otherwise, the name of the layer. + export const NetworkButton: FC = ({ isOutOfDate, layer, network, onClick }) => { const { t } = useTranslation() - const labels = getLayerLabels(t) const icons = getNetworkIcons() return ( @@ -90,7 +96,7 @@ export const NetworkButton: FC = ({ isOutOfDate, layer, netw onClick={onClick} > - {labels[layer]} + {getNetworkButtonLabel(t, network, layer)} @@ -115,11 +121,10 @@ export const StyledMobileNetworkButton = styled(Button)(({ theme }) => ({ export const MobileNetworkButton: FC = ({ isOutOfDate, layer, network, onClick }) => { const { t } = useTranslation() - const labels = getLayerLabels(t) return ( - {labels[layer]} + {getNetworkButtonLabel(t, network, layer)} ) diff --git a/src/app/components/PageLayout/NetworkSelector.tsx b/src/app/components/PageLayout/NetworkSelector.tsx index de8c428abf..14a33a57ac 100644 --- a/src/app/components/PageLayout/NetworkSelector.tsx +++ b/src/app/components/PageLayout/NetworkSelector.tsx @@ -10,7 +10,7 @@ import { COLORS } from '../../../styles/theme/colors' import { Network, getNetworkNames } from '../../../types/network' import { Layer } from '../../../oasis-nexus/api' import { LayerPicker } from './../LayerPicker' -import { RouteUtils } from '../../utils/route-utils' +import { fixedLayer, RouteUtils } from '../../utils/route-utils' import { useConsensusFreshness, useRuntimeFreshness } from '../OfflineBanner/hook' export const StyledBox = styled(Box)(({ theme }) => ({ @@ -84,7 +84,7 @@ const NetworkSelectorView: FC = ({ isOutOfDate, layer, {!isMobile && ( )} - {!isTablet && network !== Network.mainnet && ( + {!fixedLayer && !isTablet && network !== Network.mainnet && ( = ({ network, @@ -14,6 +15,8 @@ export const ThemeByNetwork: FC<{ network: Network; children: React.ReactNode }> ) -export const withDefaultTheme = (node: ReactNode) => ( - {node} +export const withDefaultTheme = (node: ReactNode, alwaysMainnet = false) => ( + + {node} + ) diff --git a/src/app/hooks/useSearchQueryNetworkParam.ts b/src/app/hooks/useSearchQueryNetworkParam.ts index ffa28e3586..2e698856cb 100644 --- a/src/app/hooks/useSearchQueryNetworkParam.ts +++ b/src/app/hooks/useSearchQueryNetworkParam.ts @@ -1,6 +1,6 @@ import { useSearchParams } from 'react-router-dom' import { Network } from '../../types/network' -import { RouteUtils } from '../utils/route-utils' +import { fixedNetwork, RouteUtils } from '../utils/route-utils' import { AppErrors } from '../../types/errors' /** @@ -12,7 +12,7 @@ export const useSearchQueryNetworkParam = (): { setNetwork: (network: Network) => void } => { const [searchParams, setSearchParams] = useSearchParams() - const networkQueryParam = searchParams.get('network') ?? Network.mainnet + const networkQueryParam = fixedNetwork ?? searchParams.get('network') ?? Network.mainnet if (!RouteUtils.getEnabledNetworks().includes(networkQueryParam as any)) { throw AppErrors.InvalidUrl } diff --git a/src/app/pages/HomePage/Graph/ParaTimeSelector/index.tsx b/src/app/pages/HomePage/Graph/ParaTimeSelector/index.tsx index 3e1daaed8e..7edb482780 100644 --- a/src/app/pages/HomePage/Graph/ParaTimeSelector/index.tsx +++ b/src/app/pages/HomePage/Graph/ParaTimeSelector/index.tsx @@ -24,6 +24,7 @@ import { useSearchQueryNetworkParam } from '../../../../hooks/useSearchQueryNetw import { storage } from '../../../../utils/storage' import { StorageKeys } from '../../../../../types/storage' import { GraphTooltipMobile } from '../GraphTooltipMobile' +import { fixedNetwork } from '../../../../utils/route-utils' interface ParaTimeSelectorBaseProps { disabled: boolean @@ -284,7 +285,7 @@ const ParaTimeSelectorCmp: FC = ({ )} - {step === ParaTimeSelectorStep.Explore && ( + {!fixedNetwork && step === ParaTimeSelectorStep.Explore && ( setNetwork(network ?? Network.mainnet)} /> )} diff --git a/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx b/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx index 223480793b..defe110fe4 100644 --- a/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx +++ b/src/app/pages/SearchResultsPage/GlobalSearchResultsView.tsx @@ -1,7 +1,7 @@ import { FC, useState } from 'react' import { useTranslation } from 'react-i18next' -import { RouteUtils } from '../../utils/route-utils' +import { fixedNetwork, RouteUtils } from '../../utils/route-utils' import { SearchResults } from './hooks' import { NoResultsOnMainnet, NoResultsWhatsoever } from './NoResults' import { SearchResultsList } from './SearchResultsList' @@ -30,6 +30,23 @@ export const GlobalSearchResultsView: FC<{ const themes = getThemesForNetworks() const networkNames = getNetworkNames(t) + + if (fixedNetwork) { + return ( + <> + {!searchResults.length && } + + + ) + } + const otherNetworks = RouteUtils.getEnabledNetworks().filter(isNotMainnet) const notificationTheme = themes[Network.testnet] const mainnetResults = searchResults.filter(isOnMainnet).sort(orderByLayer) diff --git a/src/app/pages/SearchResultsPage/hooks.ts b/src/app/pages/SearchResultsPage/hooks.ts index d02c4dcbd3..6da6903d9f 100644 --- a/src/app/pages/SearchResultsPage/hooks.ts +++ b/src/app/pages/SearchResultsPage/hooks.ts @@ -174,12 +174,10 @@ export function useRuntimeTokenConditionally( export function useNetworkProposalsConditionally( nameFragment: string | undefined, ): ConditionalResults { - const queries = RouteUtils.getEnabledNetworks() - .filter(network => RouteUtils.getAllLayersForNetwork(network).enabled.includes(Layer.consensus)) - .map(network => - // eslint-disable-next-line react-hooks/rules-of-hooks - useGetConsensusProposalsByName(network, nameFragment), - ) + const queries = RouteUtils.getEnabledNetworksForLayer(Layer.consensus).map(network => + // eslint-disable-next-line react-hooks/rules-of-hooks + useGetConsensusProposalsByName(network, nameFragment), + ) return { isLoading: queries.some(query => query.isInitialLoading), results: queries diff --git a/src/app/utils/route-utils.ts b/src/app/utils/route-utils.ts index 921d908701..38b69a4ebb 100644 --- a/src/app/utils/route-utils.ts +++ b/src/app/utils/route-utils.ts @@ -7,6 +7,24 @@ import { Network } from '../../types/network' import { SearchScope } from '../../types/searchScope' import { isStableDeploy } from '../../config' import { getSearchTermFromRequest } from '../components/Search/search-utils' +import { isLayerHidden } from '../../types/layers' + +export const fixedNetwork = process.env.REACT_APP_FIXED_NETWORK as Network | undefined +export const fixedLayer = process.env.REACT_APP_FIXED_LAYER as Layer | undefined + +export type ScopeFreedom = + | 'network' // We can select only the network + | 'layer' // We can select only the layer + | 'network-layer' // We can select both network and layer + | 'none' // We can't select anything, everything is fixed + +export const scopeFreedom: ScopeFreedom = fixedNetwork + ? fixedLayer + ? 'none' + : 'layer' + : fixedLayer + ? 'network' + : 'network-layer' export type SpecifiedPerEnabledLayer = { [N in keyof (typeof RouteUtils)['ENABLED_LAYERS_FOR_NETWORK']]: { @@ -101,7 +119,7 @@ export abstract class RouteUtils { static getSearchRoute = (scope: SearchScope | undefined, searchTerm: string) => { return scope - ? `/${scope.network}/${scope.layer}/search?q=${encodeURIComponent(searchTerm)}` + ? `/${encodeURIComponent(scope.network)}/${encodeURIComponent(scope.layer)}/search?q=${encodeURIComponent(searchTerm)}` : `/search?q=${encodeURIComponent(searchTerm)}` } @@ -130,9 +148,13 @@ export abstract class RouteUtils { const enabled: Layer[] = [] const disabled: Layer[] = [] - Object.values(Layer).forEach(layer => - RouteUtils.ENABLED_LAYERS_FOR_NETWORK[network][layer] ? enabled.push(layer) : disabled.push(layer), - ) + Object.values(Layer).forEach(layer => { + if (!!fixedLayer && layer !== fixedLayer) { + disabled.push(layer) + } else { + RouteUtils.ENABLED_LAYERS_FOR_NETWORK[network][layer] ? enabled.push(layer) : disabled.push(layer) + } + }) return { enabled, @@ -140,6 +162,12 @@ export abstract class RouteUtils { } } + static getVisibleLayersForNetwork(network: Network, currentScope: SearchScope | undefined): Layer[] { + return this.getAllLayersForNetwork(network).enabled.filter( + layer => !isLayerHidden(layer) || layer === currentScope?.layer, + ) + } + static getProposalsRoute = (network: Network) => { return `/${encodeURIComponent(network)}/consensus/proposal` } @@ -154,16 +182,32 @@ export abstract class RouteUtils { ) } + /** + * Get the list of enabled networks. + * + * If this Explorer is fixed to a specific network, the only that network will be returned. + * Furthermore, if this Explorer is fixed to a specific layer, only networks that support that layer are returned. + */ static getEnabledNetworks(): Network[] { - return Object.values(Network).filter(network => { - return RouteUtils.getAllLayersForNetwork(network).enabled.length > 0 - }) + const networks = fixedNetwork + ? [fixedNetwork] + : Object.values(Network).filter(network => { + return this.getAllLayersForNetwork(network).enabled.length > 0 + }) + return fixedLayer + ? networks.filter(network => { + return this.getAllLayersForNetwork(network).enabled.includes(fixedLayer) + }) + : networks } - static getEnabledSearchScopes(): SearchScope[] { - return RouteUtils.getEnabledNetworks().flatMap(network => - RouteUtils.getAllLayersForNetwork(network).enabled.map(layer => ({ network, layer })), - ) + static getEnabledNetworksForLayer(layer: Layer | undefined): Network[] { + const networks = this.getEnabledNetworks() + return layer + ? networks.filter(network => { + return this.getAllLayersForNetwork(network).enabled.includes(layer) + }) + : networks } } @@ -276,3 +320,19 @@ export const proposalIdParamLoader = async ({ params, request }: LoaderFunctionA searchTerm: getSearchTermFromRequest(request), } } + +/** + * Is it possible to change the scope, given the current configuration? + */ +export const isScopeSelectorNeeded = (sourceScope: SearchScope) => { + switch (scopeFreedom) { + case 'network': + return RouteUtils.getEnabledNetworks().length > 1 + case 'layer': + return RouteUtils.getVisibleLayersForNetwork(fixedNetwork!, sourceScope).length > 1 + case 'network-layer': + return RouteUtils.getEnabledScopes().length > 1 + case 'none': + return false + } +} diff --git a/src/global.d.ts b/src/global.d.ts index dc359c1039..eb21230755 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -23,6 +23,8 @@ declare global { REACT_APP_SOCIAL_HOME?: string REACT_APP_PRODUCTION_URLS: string REACT_APP_STAGING_URLS?: string + REACT_APP_FIXED_NETWORK?: string + REACT_APP_FIXED_LAYER?: string } } } diff --git a/src/routes.tsx b/src/routes.tsx index a289e816b5..8021b6bb41 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -1,4 +1,4 @@ -import { Outlet, RouteObject, ScrollRestoration } from 'react-router-dom' +import { Outlet, RouteObject, ScrollRestoration, useNavigate } from 'react-router-dom' import { HomePage } from './app/pages/HomePage' import { RuntimeBlocksPage } from './app/pages/RuntimeBlocksPage' import { RuntimeTransactionsPage } from './app/pages/RuntimeTransactionsPage' @@ -16,6 +16,9 @@ import { transactionParamLoader, assertEnabledScope, proposalIdParamLoader, + fixedNetwork, + fixedLayer, + RouteUtils, } from './app/utils/route-utils' import { searchParamLoader } from './app/components/Search/search-utils' import { RoutingErrorPage } from './app/pages/RoutingErrorPage' @@ -43,6 +46,7 @@ import { ConsensusBlocksPage } from './app/pages/ConsensusBlocksPage' import { ConsensusAccountsPage } from './app/pages/ConsensusAccountsPage' import { ConsensusTransactionsPage } from './app/pages/ConsensusTransactionsPage' import { ConsensusAccountDetailsPage } from './app/pages/ConsensusAccountDetailsPage' +import { FC, useEffect } from 'react' const NetworkSpecificPart = () => ( @@ -50,6 +54,25 @@ const NetworkSpecificPart = () => ( ) +/** + * In case of being restricted to a specific layer, jump to a dashboard + * + * This should be rendered on the landing page, since we don't want the opening graph. + */ +const RedirectToDashboard: FC = () => { + const navigate = useNavigate() + + useEffect(() => + navigate( + RouteUtils.getDashboardRoute({ + network: fixedNetwork ?? RouteUtils.getEnabledNetworksForLayer(fixedLayer!)[0]!, + layer: fixedLayer!, + }), + ), + ) + return null +} + export const routes: RouteObject[] = [ { errorElement: , @@ -62,13 +85,17 @@ export const routes: RouteObject[] = [ children: [ { path: '/', - element: withDefaultTheme(), - }, - { - path: '/search', // Global search - element: withDefaultTheme(), - loader: searchParamLoader, + element: fixedLayer ? : withDefaultTheme(, true), }, + ...(!!fixedNetwork && !!fixedLayer + ? [] + : [ + { + path: '/search', // Global search + element: withDefaultTheme(), + loader: searchParamLoader, + }, + ]), { path: '/:_network/consensus', element: ,