diff --git a/explorer/src/App.tsx b/explorer/src/App.tsx index a33b49c8ca..9ce60f06f9 100644 --- a/explorer/src/App.tsx +++ b/explorer/src/App.tsx @@ -1,13 +1,11 @@ import * as React from 'react'; -import { useMediaQuery } from '@mui/material'; -import { useTheme } from '@mui/material/styles'; import { Nav } from './components/Nav'; import { MobileNav } from './components/MobileNav'; import { Routes } from './routes/index'; +import { useIsMobile } from './hooks/useIsMobile'; export const App: React.FC = () => { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const isMobile = useIsMobile(); if (isMobile) { return ( diff --git a/explorer/src/components/Filters/Filters.tsx b/explorer/src/components/Filters/Filters.tsx new file mode 100644 index 0000000000..c62b09a53f --- /dev/null +++ b/explorer/src/components/Filters/Filters.tsx @@ -0,0 +1,161 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { Tune } from '@mui/icons-material'; +import { + Button, + Dialog, + DialogContent, + DialogActions, + DialogTitle, + IconButton, + Slider, + Typography, + Box, + Snackbar, + Slide, + Alert, +} from '@mui/material'; +import { useParams } from 'react-router-dom'; +import { useMainContext } from '../../context/main'; +import { MixnodeStatusWithAll, toMixnodeStatus } from '../../typeDefs/explorer-api'; +import { EnumFilterKey, TFilterItem, TFilters } from '../../typeDefs/filters'; +import { formatOnSave, generateFilterSchema } from './filterSchema'; +import { Api } from '../../api'; +import { useIsMobile } from '../../hooks/useIsMobile'; + +const FilterItem = ({ + label, + id, + value, + marks, + scale, + min, + max, + onChange, +}: TFilterItem & { + onChange: (id: EnumFilterKey, newValue: number[]) => void; +}) => ( + + {label} + onChange(id, newValue as number[])} + valueLabelDisplay="off" + marks={marks} + step={null} + scale={scale} + min={min} + max={max} + /> + +); + +export const Filters = () => { + const { filterMixnodes, fetchMixnodes } = useMainContext(); + const { status } = useParams<{ status: MixnodeStatusWithAll | undefined }>(); + const isMobile = useIsMobile(); + + const [showFilters, setShowFilters] = useState(false); + const [isFiltered, setIsFiltered] = useState(false); + const [filters, setFilters] = React.useState(); + + const baseFilters = useRef(); + const prevFilters = useRef(); + + const handleToggleShowFilters = () => setShowFilters(!showFilters); + + const initialiseFilters = useCallback(async () => { + let upperSaturationValue; + const allMixnodes = await Api.fetchMixnodes(); + if (allMixnodes) { + upperSaturationValue = Math.round(Math.max(...allMixnodes.map((m) => m.stake_saturation)) * 100 + 1); + const initFilters = generateFilterSchema(upperSaturationValue); + baseFilters.current = initFilters; + prevFilters.current = initFilters; + setFilters(initFilters); + } + }, []); + + const handleOnChange = (id: EnumFilterKey, newValue: number[]) => { + setFilters((ftrs) => { + if (ftrs) return { ...ftrs, [id]: { ...ftrs[id], value: newValue } }; + return undefined; + }); + }; + + const handleOnSave = async () => { + setShowFilters(false); + await filterMixnodes(formatOnSave(filters!), status); + setIsFiltered(true); + prevFilters.current = filters; + }; + + const handleOnCancel = () => { + setShowFilters(false); + setFilters(prevFilters.current); + }; + + const resetFilters = () => { + setFilters(baseFilters.current); + setIsFiltered(false); + prevFilters.current = baseFilters.current; + }; + + const onClearFilters = async () => { + await fetchMixnodes(toMixnodeStatus(status)); + resetFilters(); + }; + + useEffect(() => { + initialiseFilters(); + }, [initialiseFilters]); + + useEffect(() => { + resetFilters(); + }, [status]); + + if (!filters) return null; + + return ( + <> + + + Clear + + } + sx={{ width: 300 }} + > + Filters applied + + + + + + + Mixnode filters + + {Object.values(filters).map((v) => ( + + ))} + + + + + + + + ); +}; diff --git a/explorer/src/components/Filters/filterSchema.ts b/explorer/src/components/Filters/filterSchema.ts new file mode 100644 index 0000000000..1d6b742f90 --- /dev/null +++ b/explorer/src/components/Filters/filterSchema.ts @@ -0,0 +1,116 @@ +import { EnumFilterKey, TFilters } from '../../typeDefs/filters'; + +export const generateFilterSchema = (upperSaturationValue?: number) => ({ + profitMargin: { + label: 'Profit margin (%)', + id: EnumFilterKey.profitMargin, + value: [0, 100], + marks: [ + { label: '0', value: 0 }, + { label: '10', value: 10 }, + { label: '20', value: 20 }, + { label: '30', value: 30 }, + { label: '40', value: 40 }, + { label: '50', value: 50 }, + { label: '60', value: 60 }, + { label: '70', value: 70 }, + { label: '80', value: 80 }, + { label: '90', value: 90 }, + { label: '100', value: 100 }, + ], + }, + stakeSaturation: { + label: 'Stake saturation (%)', + id: EnumFilterKey.stakeSaturation, + value: [0, upperSaturationValue || 100], + marks: [ + { label: '0', value: 0 }, + + { + label: '10', + value: 10, + }, + + { + label: '50', + value: 50, + }, + { label: '90', value: 90 }, + + { + label: upperSaturationValue ? `${upperSaturationValue}` : '100', + value: upperSaturationValue || 100, + }, + ], + max: upperSaturationValue, + }, + stake: { + label: 'Stake (NYM)', + id: EnumFilterKey.stake, + value: [20, 90], + min: 20, + max: 90, + marks: [ + { + value: 0, + label: '1', + }, + { + value: 10, + label: '10', + }, + { + value: 20, + label: '100', + }, + { + value: 30, + label: '1k', + }, + { + value: 40, + label: '10k', + }, + { + value: 50, + label: '100k', + }, + { + value: 60, + label: '1M', + }, + { + value: 70, + label: '10M', + }, + { + value: 80, + label: '100M', + }, + { + value: 90, + label: '1B', + }, + ], + }, +}); + +const formatStakeValuesToMinorDenom = ([value_1, value_2]: number[]) => { + const lowerValue = 10 ** (value_1 / 10) * 1_000_000; + const upperValue = 10 ** (value_2 / 10) * 1_000_000; + + return [lowerValue, upperValue]; +}; + +const formatStakeSaturationValues = ([value_1, value_2]: number[]) => { + const lowerValue = value_1 / 100; + const upperValue = value_2 / 100; + + return [lowerValue, upperValue]; +}; + +export const formatOnSave = (filters: TFilters) => ({ + stake: formatStakeValuesToMinorDenom(filters.stake.value), + profitMargin: filters.profitMargin.value, + stakeSaturation: formatStakeSaturationValues(filters.stakeSaturation.value), +}); diff --git a/explorer/src/components/Footer.tsx b/explorer/src/components/Footer.tsx index 1696def9ee..2ee7c77198 100644 --- a/explorer/src/components/Footer.tsx +++ b/explorer/src/components/Footer.tsx @@ -1,12 +1,11 @@ import * as React from 'react'; import Box from '@mui/material/Box'; -import { Typography, useMediaQuery } from '@mui/material'; -import { useTheme } from '@mui/material/styles'; +import { Typography } from '@mui/material'; import { Socials } from './Socials'; +import { useIsMobile } from '../hooks/useIsMobile'; export const Footer: React.FC = () => { - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const isMobile = useIsMobile(); return ( { sx={{ fontSize: 12, textAlign: isMobile ? 'center' : 'end', - color: theme.palette.nym.muted.onDarkBg, + color: 'nym.muted.onDarkBg', }} > © {new Date().getFullYear()} Nym Technologies SA, all rights reserved diff --git a/explorer/src/components/MixNodes/BondBreakdown.tsx b/explorer/src/components/MixNodes/BondBreakdown.tsx index 4b2dc58d92..06009be05d 100644 --- a/explorer/src/components/MixNodes/BondBreakdown.tsx +++ b/explorer/src/components/MixNodes/BondBreakdown.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { Alert, Box, CircularProgress, useMediaQuery, Typography } from '@mui/material'; +import { Alert, Box, CircularProgress, Typography } from '@mui/material'; import { useTheme } from '@mui/material/styles'; import Table from '@mui/material/Table'; import TableBody from '@mui/material/TableBody'; @@ -11,6 +11,7 @@ import Paper from '@mui/material/Paper'; import { ExpandMore } from '@mui/icons-material'; import { currencyToString } from '../../utils/currency'; import { useMixnodeContext } from '../../context/mixnode'; +import { useIsMobile } from '../../hooks/useIsMobile'; export const BondBreakdownTable: React.FC = () => { const { mixNode, delegations, uniqDelegations } = useMixnodeContext(); @@ -23,7 +24,7 @@ export const BondBreakdownTable: React.FC = () => { hasLoaded: false, }); const theme = useTheme(); - const matches = useMediaQuery(theme.breakpoints.down('sm')); + const isMobile = useIsMobile(); React.useEffect(() => { if (mixNode?.data) { @@ -83,7 +84,7 @@ export const BondBreakdownTable: React.FC = () => { - + { {uniqDelegations?.data?.map(({ owner, amount: { amount, denom } }) => ( - + {owner} {currencyToString(amount.toString(), denom)} diff --git a/explorer/src/components/MixNodes/DetailSection.tsx b/explorer/src/components/MixNodes/DetailSection.tsx index 6ffe7af34e..b338487fd2 100644 --- a/explorer/src/components/MixNodes/DetailSection.tsx +++ b/explorer/src/components/MixNodes/DetailSection.tsx @@ -1,10 +1,10 @@ -import { Box, Button, Grid, Typography, useMediaQuery } from '@mui/material'; import * as React from 'react'; +import { Box, Button, Grid, Typography, useTheme } from '@mui/material'; import Identicon from 'react-identicons'; -import { useTheme } from '@mui/material/styles'; import { MixnodeRowType } from '.'; import { getMixNodeStatusText, MixNodeStatus } from './Status'; import { MixNodeDescriptionResponse } from '../../typeDefs/explorer-api'; +import { useIsMobile } from '../../hooks/useIsMobile'; interface MixNodeDetailProps { mixNodeRow: MixnodeRowType; @@ -14,7 +14,7 @@ interface MixNodeDetailProps { export const MixNodeDetailSection: React.FC = ({ mixNodeRow, mixnodeDescription }) => { const theme = useTheme(); const palette = [theme.palette.text.primary]; - const matches = useMediaQuery(theme.breakpoints.down('sm')); + const isMobile = useIsMobile(); const statusText = React.useMemo(() => getMixNodeStatusText(mixNodeRow.status), [mixNodeRow.status]); return ( @@ -64,7 +64,7 @@ export const MixNodeDetailSection: React.FC = ({ mixNodeRow, - + Node status: diff --git a/explorer/src/components/MixNodes/Economics/Table.tsx b/explorer/src/components/MixNodes/Economics/Table.tsx index 12c67e9323..d60a83da84 100644 --- a/explorer/src/components/MixNodes/Economics/Table.tsx +++ b/explorer/src/components/MixNodes/Economics/Table.tsx @@ -1,15 +1,5 @@ import * as React from 'react'; -import { - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Typography, - useMediaQuery, -} from '@mui/material'; +import { Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material'; import { Box } from '@mui/system'; import { useTheme, Theme } from '@mui/material/styles'; import { Tooltip } from '@nymproject/react/tooltip/Tooltip'; @@ -17,6 +7,7 @@ import { EconomicsRowsType, EconomicsInfoRowWithIndex } from './types'; import { EconomicsProgress } from './EconomicsProgress'; import { cellStyles } from '../../Universal-DataGrid'; import { UniversalTableProps } from '../../DetailTable'; +import { useIsMobile } from '../../../hooks/useIsMobile'; const threshold = 100; @@ -44,8 +35,8 @@ const textColour = (value: EconomicsRowsType, field: string, theme: Theme) => { return theme.palette.nym.wallet.fee; }; -const formatCellValues = (value: EconomicsRowsType, field: string, theme: Theme) => { - const isTablet = useMediaQuery(theme.breakpoints.down('lg')); +const formatCellValues = (value: EconomicsRowsType, field: string) => { + const isTablet = useIsMobile('lg'); if (value.progressBarValue) { return ( @@ -130,7 +121,7 @@ export const DelegatorsInfoTable: React.FC - {formatCellValues(value, columnsData[index].field, theme)} + {formatCellValues(value, columnsData[index].field)} ); })} diff --git a/explorer/src/components/MixNodes/StatusDropdown.tsx b/explorer/src/components/MixNodes/StatusDropdown.tsx index 6856beca4a..1dba2c2f95 100644 --- a/explorer/src/components/MixNodes/StatusDropdown.tsx +++ b/explorer/src/components/MixNodes/StatusDropdown.tsx @@ -1,11 +1,11 @@ -import { MenuItem, useMediaQuery } from '@mui/material'; import * as React from 'react'; +import { MenuItem } from '@mui/material'; import Select from '@mui/material/Select'; import { SelectInputProps } from '@mui/material/Select/SelectInput'; -import { useTheme } from '@mui/material/styles'; import { SxProps } from '@mui/system'; import { MixNodeStatus } from './Status'; import { MixnodeStatus, MixnodeStatusWithAll } from '../../typeDefs/explorer-api'; +import { useIsMobile } from '../../hooks/useIsMobile'; // TODO: replace with i18n const ALL_NODES = 'All nodes'; @@ -17,8 +17,7 @@ interface MixNodeStatusDropdownProps { } export const MixNodeStatusDropdown: React.FC = ({ status, onSelectionChanged, sx }) => { - const theme = useTheme(); - const matches = useMediaQuery(theme.breakpoints.down('sm')); + const isMobile = useIsMobile(); const [statusValue, setStatusValue] = React.useState(status || MixnodeStatusWithAll.all); const onChange: SelectInputProps['onChange'] = React.useCallback( ({ target: { value } }) => { @@ -47,7 +46,7 @@ export const MixNodeStatusDropdown: React.FC = ({ st } }} sx={{ - width: matches ? 'auto' : 200, + width: isMobile ? 'auto' : 200, ...sx, }} > diff --git a/explorer/src/components/TableToolbar.tsx b/explorer/src/components/TableToolbar.tsx index 05f6b8b651..c6a64dd6c5 100644 --- a/explorer/src/components/TableToolbar.tsx +++ b/explorer/src/components/TableToolbar.tsx @@ -1,13 +1,15 @@ import * as React from 'react'; -import { Box, useMediaQuery, TextField, MenuItem } from '@mui/material'; -import { useTheme } from '@mui/material/styles'; +import { Box, TextField, MenuItem } from '@mui/material'; import Select, { SelectChangeEvent } from '@mui/material/Select'; +import { Filters } from './Filters/Filters'; +import { useIsMobile } from '../hooks/useIsMobile'; type TableToolBarProps = { onChangeSearch: (arg: string) => void; onChangePageSize: (event: SelectChangeEvent) => void; pageSize: string; searchTerm: string; + withFilters?: boolean; childrenBefore?: React.ReactNode; childrenAfter?: React.ReactNode; }; @@ -19,16 +21,16 @@ export const TableToolbar: React.FC = ({ pageSize, childrenBefore, childrenAfter, + withFilters, }) => { - const theme = useTheme(); - const matches = useMediaQuery(theme.breakpoints.down('sm')); + const isMobile = useIsMobile(); return ( @@ -40,7 +42,7 @@ export const TableToolbar: React.FC = ({ value={pageSize} onChange={onChangePageSize} sx={{ - width: matches ? 100 : 200, + width: isMobile ? 100 : 200, }} > @@ -57,17 +59,18 @@ export const TableToolbar: React.FC = ({ - + onChangeSearch(event.target.value)} /> + {withFilters && } {childrenAfter} diff --git a/explorer/src/context/main.tsx b/explorer/src/context/main.tsx index 4663cd2644..7fca785209 100644 --- a/explorer/src/context/main.tsx +++ b/explorer/src/context/main.tsx @@ -10,6 +10,7 @@ import { SummaryOverviewResponse, ValidatorsResponse, } from '../typeDefs/explorer-api'; +import { EnumFilterKey } from '../typeDefs/filters'; import { Api } from '../api'; import { NavOptionType, originalNavOptions } from './nav'; @@ -26,8 +27,8 @@ interface StateData { } interface StateApi { - fetchMixnodes: (status?: MixnodeStatus) => void; - filterMixnodes: (mixnodes: MixNodeResponse) => void; + fetchMixnodes: (status?: MixnodeStatus) => Promise; + filterMixnodes: (filters: any, status: any) => void; toggleMode: () => void; updateNavState: (id: number) => void; } @@ -40,7 +41,7 @@ export const MainContext = React.createContext({ navState: originalNavOptions, toggleMode: () => undefined, filterMixnodes: () => null, - fetchMixnodes: () => null, + fetchMixnodes: () => Promise.resolve(undefined), }); export const useMainContext = (): React.ContextType => React.useContext(MainContext); @@ -78,9 +79,10 @@ export const MainContextProvider: React.FC = ({ children }) => { }; const fetchMixnodes = async (status?: MixnodeStatus) => { + let data; setMixnodes((d) => ({ ...d, isLoading: true })); try { - const data = status ? await Api.fetchMixnodesActiveSetByStatus(status) : await Api.fetchMixnodes(); + data = status ? await Api.fetchMixnodesActiveSetByStatus(status) : await Api.fetchMixnodes(); setMixnodes({ data, isLoading: false }); } catch (error) { setMixnodes({ @@ -88,10 +90,24 @@ export const MainContextProvider: React.FC = ({ children }) => { isLoading: false, }); } + return data; }; - const filterMixnodes = (arr: MixNodeResponse) => { - setMixnodes({ data: arr, isLoading: false }); + + const filterMixnodes = async (filters: { [key in EnumFilterKey]: number[] }, status?: MixnodeStatus) => { + setMixnodes((d) => ({ ...d, isLoading: true })); + const mxns = status ? await Api.fetchMixnodesActiveSetByStatus(status) : await Api.fetchMixnodes(); + const filtered = mxns?.filter( + (m) => + m.mix_node.profit_margin_percent >= filters.profitMargin[0] && + m.mix_node.profit_margin_percent <= filters.profitMargin[1] && + m.stake_saturation >= filters.stakeSaturation[0] && + m.stake_saturation <= filters.stakeSaturation[1] && + +m.pledge_amount.amount + +m.total_delegation.amount >= filters.stake[0] && + +m.pledge_amount.amount + +m.total_delegation.amount <= filters.stake[1], + ); + setMixnodes({ data: filtered, isLoading: false }); }; + const fetchGateways = async () => { try { const data = await Api.fetchGateways(); @@ -144,6 +160,7 @@ export const MainContextProvider: React.FC = ({ children }) => { })); updateNav(updated); }; + React.useEffect(() => { Promise.all([fetchOverviewSummary(), fetchGateways(), fetchValidators(), fetchBlock(), fetchCountryData()]); }, []); diff --git a/explorer/src/hooks/useIsMobile.ts b/explorer/src/hooks/useIsMobile.ts new file mode 100644 index 0000000000..225964b464 --- /dev/null +++ b/explorer/src/hooks/useIsMobile.ts @@ -0,0 +1,9 @@ +import { Breakpoint, useMediaQuery } from '@mui/material'; +import { useTheme } from '@mui/material/styles'; + +export const useIsMobile = (queryInput: number | Breakpoint = 'md') => { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down(queryInput)); + + return isMobile; +}; diff --git a/explorer/src/pages/Mixnodes/index.tsx b/explorer/src/pages/Mixnodes/index.tsx index 4427ba8b46..33ebb84993 100644 --- a/explorer/src/pages/Mixnodes/index.tsx +++ b/explorer/src/pages/Mixnodes/index.tsx @@ -290,6 +290,7 @@ export const PageMixnodes: React.FC = () => { onChangePageSize={handlePageSize} pageSize={pageSize} searchTerm={searchTerm} + withFilters /> number; +}; + +export type TFilters = { [key in EnumFilterKey]: TFilterItem };