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
+
+
+
+
+
+
+ >
+ );
+};
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,
}}
>
-
+
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 };