Network Explorer: Mixnode filters (#1460)

* add filters UI

* use filter schema

* filter mixnode based on selected filters

* only show filters on the mixnode page

* use base api to get all mixnodes to avoid setting mixnodes in state

* prevent additional request when status changes

* create isMobile hook
This commit is contained in:
Fouad
2022-07-18 15:18:39 +01:00
committed by GitHub
parent d62e13c932
commit c485934b06
13 changed files with 366 additions and 51 deletions
+2 -4
View File
@@ -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 (
+161
View File
@@ -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;
}) => (
<Box sx={{ p: 2 }}>
<Typography gutterBottom>{label}</Typography>
<Slider
value={value}
onChange={(e: Event, newValue: number | number[]) => onChange(id, newValue as number[])}
valueLabelDisplay="off"
marks={marks}
step={null}
scale={scale}
min={min}
max={max}
/>
</Box>
);
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<TFilters>();
const baseFilters = useRef<TFilters>();
const prevFilters = useRef<TFilters>();
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 (
<>
<Snackbar
open={isFiltered}
anchorOrigin={{ vertical: 'top', horizontal: 'center' }}
message="Filters applied"
TransitionComponent={Slide}
transitionDuration={250}
>
<Alert
severity="info"
variant={isMobile ? 'standard' : 'outlined'}
action={
<Button size="small" onClick={onClearFilters}>
Clear
</Button>
}
sx={{ width: 300 }}
>
Filters applied
</Alert>
</Snackbar>
<IconButton size="large" onClick={handleToggleShowFilters}>
<Tune />
</IconButton>
<Dialog open={showFilters} onClose={handleToggleShowFilters} maxWidth="md" fullWidth>
<DialogTitle>Mixnode filters</DialogTitle>
<DialogContent dividers>
{Object.values(filters).map((v) => (
<FilterItem {...v} key={v.id} onChange={handleOnChange} />
))}
</DialogContent>
<DialogActions>
<Button size="large" onClick={handleOnCancel}>
Cancel
</Button>
<Button variant="contained" size="large" onClick={handleOnSave}>
Save
</Button>
</DialogActions>
</Dialog>
</>
);
};
@@ -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),
});
+4 -5
View File
@@ -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 (
<Box
@@ -39,7 +38,7 @@ export const Footer: React.FC = () => {
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
@@ -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 = () => {
<TableContainer component={Paper}>
<Table sx={{ minWidth: 650 }} aria-label="bond breakdown totals">
<TableBody>
<TableRow sx={matches ? { minWidth: '70vw' } : null}>
<TableRow sx={isMobile ? { minWidth: '70vw' } : null}>
<TableCell
sx={{
fontWeight: 400,
@@ -187,7 +188,7 @@ export const BondBreakdownTable: React.FC = () => {
<TableBody>
{uniqDelegations?.data?.map(({ owner, amount: { amount, denom } }) => (
<TableRow key={owner}>
<TableCell sx={matches ? { width: 190 } : null} align="left">
<TableCell sx={isMobile ? { width: 190 } : null} align="left">
{owner}
</TableCell>
<TableCell align="left">{currencyToString(amount.toString(), denom)}</TableCell>
@@ -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<MixNodeDetailProps> = ({ 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 (
<Grid container>
@@ -64,7 +64,7 @@ export const MixNodeDetailSection: React.FC<MixNodeDetailProps> = ({ mixNodeRow,
</Box>
</Box>
</Grid>
<Grid item xs={12} sm={6} display="flex" justifyContent="end" mt={matches ? 5 : undefined}>
<Grid item xs={12} sm={6} display="flex" justifyContent="end" mt={isMobile ? 5 : undefined}>
<Box display="flex" flexDirection="column">
<Typography fontWeight="600" alignSelf="self-end">
Node status:
@@ -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 (
<Box sx={{ display: 'flex', alignItems: 'center', flexDirection: isTablet ? 'column' : 'row' }} id="field">
@@ -130,7 +121,7 @@ export const DelegatorsInfoTable: React.FC<UniversalTableProps<EconomicsInfoRowW
}}
data-testid={`${_.title.replace(/ /g, '-')}-value`}
>
{formatCellValues(value, columnsData[index].field, theme)}
{formatCellValues(value, columnsData[index].field)}
</TableCell>
);
})}
@@ -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<MixNodeStatusDropdownProps> = ({ status, onSelectionChanged, sx }) => {
const theme = useTheme();
const matches = useMediaQuery(theme.breakpoints.down('sm'));
const isMobile = useIsMobile();
const [statusValue, setStatusValue] = React.useState<MixnodeStatusWithAll>(status || MixnodeStatusWithAll.all);
const onChange: SelectInputProps<MixnodeStatusWithAll>['onChange'] = React.useCallback(
({ target: { value } }) => {
@@ -47,7 +46,7 @@ export const MixNodeStatusDropdown: React.FC<MixNodeStatusDropdownProps> = ({ st
}
}}
sx={{
width: matches ? 'auto' : 200,
width: isMobile ? 'auto' : 200,
...sx,
}}
>
+12 -9
View File
@@ -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<string>) => void;
pageSize: string;
searchTerm: string;
withFilters?: boolean;
childrenBefore?: React.ReactNode;
childrenAfter?: React.ReactNode;
};
@@ -19,16 +21,16 @@ export const TableToolbar: React.FC<TableToolBarProps> = ({
pageSize,
childrenBefore,
childrenAfter,
withFilters,
}) => {
const theme = useTheme();
const matches = useMediaQuery(theme.breakpoints.down('sm'));
const isMobile = useIsMobile();
return (
<Box
sx={{
width: '100%',
marginBottom: 2,
display: 'flex',
flexDirection: matches ? 'column-reverse' : 'row',
flexDirection: isMobile ? 'column-reverse' : 'row',
justifyContent: 'space-between',
}}
>
@@ -40,7 +42,7 @@ export const TableToolbar: React.FC<TableToolBarProps> = ({
value={pageSize}
onChange={onChangePageSize}
sx={{
width: matches ? 100 : 200,
width: isMobile ? 100 : 200,
}}
>
<MenuItem value={10} data-testid="ten">
@@ -57,17 +59,18 @@ export const TableToolbar: React.FC<TableToolBarProps> = ({
</MenuItem>
</Select>
</Box>
<Box sx={{ display: 'flex', alignItems: 'middle' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<TextField
sx={{
width: matches ? '100%' : 350,
marginBottom: matches ? 2 : 0,
width: isMobile ? '100%' : 350,
marginBottom: isMobile ? 2 : 0,
}}
value={searchTerm}
data-testid="search-box"
placeholder="search"
onChange={(event) => onChangeSearch(event.target.value)}
/>
{withFilters && <Filters />}
{childrenAfter}
</Box>
</Box>
+23 -6
View File
@@ -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<MixNodeResponse | undefined>;
filterMixnodes: (filters: any, status: any) => void;
toggleMode: () => void;
updateNavState: (id: number) => void;
}
@@ -40,7 +41,7 @@ export const MainContext = React.createContext<State>({
navState: originalNavOptions,
toggleMode: () => undefined,
filterMixnodes: () => null,
fetchMixnodes: () => null,
fetchMixnodes: () => Promise.resolve(undefined),
});
export const useMainContext = (): React.ContextType<typeof MainContext> => React.useContext<State>(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()]);
}, []);
+9
View File
@@ -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;
};
+1
View File
@@ -290,6 +290,7 @@ export const PageMixnodes: React.FC = () => {
onChangePageSize={handlePageSize}
pageSize={pageSize}
searchTerm={searchTerm}
withFilters
/>
<UniversalDataGrid
pagination
+20
View File
@@ -0,0 +1,20 @@
// eslint-disable-next-line import/no-extraneous-dependencies
import { Mark } from '@mui/base';
export enum EnumFilterKey {
profitMargin = 'profitMargin',
stakeSaturation = 'stakeSaturation',
stake = 'stake',
}
export type TFilterItem = {
label: string;
id: EnumFilterKey;
value: number[];
marks: Mark[];
min?: number;
max?: number;
scale?: (value: number) => number;
};
export type TFilters = { [key in EnumFilterKey]: TFilterItem };