Commit 8372f4a9 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #178 from blockscout/tx-internal-filters-and-search

tx page: filters and search
parents 33b1ad24 7a7fdc05
import type { TxInternalsType } from 'types/api/tx';
export const data = [ export const data = [
{ {
id: 1, id: 1,
type: 'call', type: 'call' as TxInternalsType,
status: 'success' as const, status: 'success' as const,
from: '0x12E80C27BfFBB76b4A8d26FF2bfd3C9f310FFA01', from: '0x12E80C27BfFBB76b4A8d26FF2bfd3C9f310FFA01',
to: '0xF7A558692dFB5F456e291791da7FAE8Dd046574e', to: '0xF7A558692dFB5F456e291791da7FAE8Dd046574e',
...@@ -10,7 +12,7 @@ export const data = [ ...@@ -10,7 +12,7 @@ export const data = [
}, },
{ {
id: 2, id: 2,
type: 'delegate call', type: 'delegate_call' as TxInternalsType,
status: 'error' as const, status: 'error' as const,
from: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45', from: '0x68b3465833fb72A70ecDF485E0e4C7bD8665Fc45',
to: '0x12E80C27BfFBB76b4A8d26FF2bfd3C9f310FFA01', to: '0x12E80C27BfFBB76b4A8d26FF2bfd3C9f310FFA01',
...@@ -19,7 +21,7 @@ export const data = [ ...@@ -19,7 +21,7 @@ export const data = [
}, },
{ {
id: 3, id: 3,
type: 'static call', type: 'static_call' as TxInternalsType,
status: 'success' as const, status: 'success' as const,
from: '0x97Aa2EfcF35c0f4c9AaDDCa8c2330fa7A9533830', from: '0x97Aa2EfcF35c0f4c9AaDDCa8c2330fa7A9533830',
to: '0x35317007D203b8a86CA727ad44E473E40450E378', to: '0x35317007D203b8a86CA727ad44E473E40450E378',
......
export type TxInternalsType = 'call' | 'delegate_call' | 'static_call' | 'create' | 'create2' | 'self_destruct' | 'reward'
...@@ -3,6 +3,7 @@ import React from 'react'; ...@@ -3,6 +3,7 @@ import React from 'react';
import type { AppItemPreview } from 'types/client/apps'; import type { AppItemPreview } from 'types/client/apps';
import { apos } from 'lib/html-entities';
import AppCard from 'ui/apps/AppCard'; import AppCard from 'ui/apps/AppCard';
import EmptySearchResult from 'ui/apps/EmptySearchResult'; import EmptySearchResult from 'ui/apps/EmptySearchResult';
...@@ -42,7 +43,7 @@ const AppList = ({ apps }: Props) => { ...@@ -42,7 +43,7 @@ const AppList = ({ apps }: Props) => {
)) } )) }
</Grid> </Grid>
) : ( ) : (
<EmptySearchResult/> <EmptySearchResult text={ `Couldn${ apos }t find an app that matches your filter query.` }/>
) } ) }
</> </>
); );
......
...@@ -2,9 +2,12 @@ import { Box, Heading, Icon, Text } from '@chakra-ui/react'; ...@@ -2,9 +2,12 @@ import { Box, Heading, Icon, Text } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import emptyIcon from 'icons/empty_search_result.svg'; import emptyIcon from 'icons/empty_search_result.svg';
import { apos } from 'lib/html-entities';
const EmptySearchResult = () => { interface Props {
text: string;
}
const EmptySearchResult = ({ text }: Props) => {
return ( return (
<Box <Box
display="flex" display="flex"
...@@ -31,7 +34,7 @@ const EmptySearchResult = () => { ...@@ -31,7 +34,7 @@ const EmptySearchResult = () => {
variant="secondary" variant="secondary"
align="center" align="center"
> >
Couldn{ apos }t find an app that matches your filter query. { text }
</Text> </Text>
</Box> </Box>
); );
......
...@@ -5,8 +5,8 @@ import type { AppItemOverview } from 'types/client/apps'; ...@@ -5,8 +5,8 @@ import type { AppItemOverview } from 'types/client/apps';
import { TEMPORARY_DEMO_APPS } from 'data/apps'; import { TEMPORARY_DEMO_APPS } from 'data/apps';
import AppList from 'ui/apps/AppList'; import AppList from 'ui/apps/AppList';
import FilterInput from 'ui/shared/FilterInput';
import AppModal from 'ui/apps/AppModal'; import AppModal from 'ui/apps/AppModal';
import FilterInput from 'ui/apps/FilterInput';
const defaultDisplayedApps = [ ...TEMPORARY_DEMO_APPS ] const defaultDisplayedApps = [ ...TEMPORARY_DEMO_APPS ]
.sort((a, b) => a.title.localeCompare(b.title)); .sort((a, b) => a.title.localeCompare(b.title));
...@@ -29,7 +29,7 @@ const Apps = () => { ...@@ -29,7 +29,7 @@ const Apps = () => {
return ( return (
<> <>
<FilterInput onChange={ debounceFilterApps }/> <FilterInput onChange={ debounceFilterApps } marginBottom={{ base: '4', lg: '6' }} placeholder="Find app"/>
<AppList apps={ displayedApps }/> <AppList apps={ displayedApps }/>
<AppModal <AppModal
id={ displayedAppId } id={ displayedAppId }
......
import { Button, Circle, Icon, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
import filterIcon from 'icons/filter.svg';
const FilterIcon = <Icon as={ filterIcon } boxSize={ 5 }/>;
interface Props {
isActive: boolean;
isCollapsed?: boolean;
appliedFiltersNum?: number;
onClick: () => void;
}
const FilterButton = ({ isActive, appliedFiltersNum, onClick, isCollapsed }: Props, ref: React.ForwardedRef<HTMLButtonElement>) => {
const badgeColor = useColorModeValue('white', 'black');
const badgeBgColor = useColorModeValue('blue.700', 'gray.50');
return (
<Button
ref={ ref }
leftIcon={ isCollapsed ? undefined : FilterIcon }
rightIcon={ appliedFiltersNum ? <Circle bg={ badgeBgColor } size={ 5 } color={ badgeColor }>{ appliedFiltersNum }</Circle> : undefined }
size="sm"
variant="outline"
colorScheme="gray-dark"
onClick={ onClick }
isActive={ isActive }
px={ 1.5 }
>
{ isCollapsed ? FilterIcon : 'Filter' }
</Button>
);
};
export default React.forwardRef(FilterButton);
import { SearchIcon } from '@chakra-ui/icons'; import { SearchIcon } from '@chakra-ui/icons';
import { Input, InputGroup, InputLeftElement, useColorModeValue } from '@chakra-ui/react'; import { Input, InputGroup, InputLeftElement, useColorModeValue, chakra } from '@chakra-ui/react';
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
import React, { useCallback, useState } from 'react'; import React, { useCallback, useState } from 'react';
type Props = { type Props = {
onChange: (q: string) => void; onChange: (searchTerm: string) => void;
className?: string;
size?: 'xs' | 'sm' | 'md' | 'lg';
placeholder: string;
} }
const FilterInput = ({ onChange }: Props) => { const FilterInput = ({ onChange, className, size = 'sm', placeholder }: Props) => {
const [ filterQuery, setFilterQuery ] = useState(''); const [ filterQuery, setFilterQuery ] = useState('');
const handleFilterQueryChange = useCallback((event: ChangeEvent<HTMLInputElement>) => { const handleFilterQueryChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
...@@ -19,7 +22,8 @@ const FilterInput = ({ onChange }: Props) => { ...@@ -19,7 +22,8 @@ const FilterInput = ({ onChange }: Props) => {
return ( return (
<InputGroup <InputGroup
size="sm" size={ size }
className={ className }
> >
<InputLeftElement <InputLeftElement
pointerEvents="none" pointerEvents="none"
...@@ -28,13 +32,13 @@ const FilterInput = ({ onChange }: Props) => { ...@@ -28,13 +32,13 @@ const FilterInput = ({ onChange }: Props) => {
</InputLeftElement> </InputLeftElement>
<Input <Input
size="sm" size={ size }
value={ filterQuery } value={ filterQuery }
onChange={ handleFilterQueryChange } onChange={ handleFilterQueryChange }
marginBottom={{ base: '4', lg: '6' }} placeholder={ placeholder }
/> />
</InputGroup> </InputGroup>
); );
}; };
export default FilterInput; export default chakra(FilterInput);
// DEPRECATED
// migrate to separate components
// ui/shared/FilterButton.tsx + custom filter
// ui/shared/FilterInput.tsx
import { SearchIcon } from '@chakra-ui/icons'; import { SearchIcon } from '@chakra-ui/icons';
import { Flex, Icon, Button, Circle, InputGroup, InputLeftElement, Input, useColorModeValue } from '@chakra-ui/react'; import { Flex, Icon, Button, Circle, InputGroup, InputLeftElement, Input, useColorModeValue } from '@chakra-ui/react';
import type { ChangeEvent } from 'react'; import type { ChangeEvent } from 'react';
......
import { Box, Table, Thead, Tbody, Tr, Th, TableContainer } from '@chakra-ui/react'; import { Box, Flex, Table, Thead, Tbody, Tr, Th, TableContainer } from '@chakra-ui/react';
import React from 'react'; import React from 'react';
import type { TxInternalsType } from 'types/api/tx';
import type ArrayElement from 'types/utils/ArrayElement';
import { data } from 'data/txInternal'; import { data } from 'data/txInternal';
import Filters from 'ui/shared/Filters'; import { apos } from 'lib/html-entities';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import FilterInput from 'ui/shared/FilterInput';
import TxInternalsFilter from 'ui/tx/internals/TxInternalsFilter';
import TxInternalsTableItem from 'ui/tx/internals/TxInternalsTableItem'; import TxInternalsTableItem from 'ui/tx/internals/TxInternalsTableItem';
const searchFn = (searchTerm: string) => (item: ArrayElement<typeof data>): boolean => {
const formattedSearchTerm = searchTerm.toLowerCase();
return item.type.toLowerCase().includes(formattedSearchTerm) ||
item.from.toLowerCase().includes(formattedSearchTerm) ||
item.to.toLowerCase().includes(formattedSearchTerm);
};
const TxInternals = () => { const TxInternals = () => {
return ( const [ filters, setFilters ] = React.useState<Array<TxInternalsType>>([]);
<Box> const [ searchTerm, setSearchTerm ] = React.useState<string>('');
<Filters/>
<TableContainer width="100%" mt={ 6 }> const handleFilterChange = React.useCallback((nextValue: Array<TxInternalsType>) => {
setFilters(nextValue);
}, []);
const content = (() => {
const filteredData = data
.filter(({ type }) => filters.length > 0 ? filters.includes(type) : true)
.filter(searchFn(searchTerm));
if (filteredData.length === 0) {
return <EmptySearchResult text={ `Couldn${ apos }t find any transaction that matches your query.` }/>;
}
return (
<TableContainer width="100%">
<Table variant="simple" minWidth="950px" size="sm"> <Table variant="simple" minWidth="950px" size="sm">
<Thead> <Thead>
<Tr> <Tr>
...@@ -21,15 +48,20 @@ const TxInternals = () => { ...@@ -21,15 +48,20 @@ const TxInternals = () => {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{ data.map((item) => ( { filteredData.map((item) => <TxInternalsTableItem key={ item.id } { ...item }/>) }
<TxInternalsTableItem
key={ item.id }
{ ...item }
/>
)) }
</Tbody> </Tbody>
</Table> </Table>
</TableContainer> </TableContainer>
);
})();
return (
<Box>
<Flex mb={ 6 }>
<TxInternalsFilter onFilterChange={ handleFilterChange } defaultFilters={ filters } appliedFiltersNum={ filters.length }/>
<FilterInput onChange={ setSearchTerm } maxW="360px" ml={ 3 } size="xs" placeholder="Search by addresses, hash, method..."/>
</Flex>
{ content }
</Box> </Box>
); );
}; };
......
import { Popover, PopoverTrigger, PopoverContent, PopoverBody, CheckboxGroup, Checkbox, Text, useDisclosure } from '@chakra-ui/react';
import React from 'react';
import type { TxInternalsType } from 'types/api/tx';
import useIsMobile from 'lib/hooks/useIsMobile';
import FilterButton from 'ui/shared/FilterButton';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
interface Props {
appliedFiltersNum?: number;
defaultFilters: Array<TxInternalsType>;
onFilterChange: (nextValue: Array<TxInternalsType>) => void;
}
const TxInternalsFilter = ({ onFilterChange, defaultFilters, appliedFiltersNum }: Props) => {
const { isOpen, onToggle, onClose } = useDisclosure();
const isMobile = useIsMobile();
return (
<Popover isOpen={ isOpen } onClose={ onClose } placement="bottom-start" isLazy>
<PopoverTrigger>
<FilterButton
isActive={ isOpen || Number(appliedFiltersNum) > 0 }
isCollapsed={ isMobile }
onClick={ onToggle }
appliedFiltersNum={ appliedFiltersNum }
/>
</PopoverTrigger>
<PopoverContent w={{ md: '100%', lg: '438px' }}>
<PopoverBody px={ 4 } py={ 6 } display="grid" gridTemplateColumns="1fr 1fr" rowGap={ 5 }>
<CheckboxGroup size="lg" onChange={ onFilterChange } defaultValue={ defaultFilters }>
{ TX_INTERNALS_ITEMS.map(({ title, id }) => <Checkbox key={ id } value={ id }><Text fontSize="md">{ title }</Text></Checkbox>) }
</CheckboxGroup>
</PopoverBody>
</PopoverContent>
</Popover>
);
};
export default React.memo(TxInternalsFilter);
import { Tr, Td, Tag, Icon } from '@chakra-ui/react'; import { Tr, Td, Tag, Icon } from '@chakra-ui/react';
import capitalize from 'lodash/capitalize';
import React from 'react'; import React from 'react';
import rightArrowIcon from 'icons/arrows/right.svg'; import rightArrowIcon from 'icons/arrows/right.svg';
import Address from 'ui/shared/address/Address'; import Address from 'ui/shared/address/Address';
import AddressIcon from 'ui/shared/address/AddressIcon'; import AddressIcon from 'ui/shared/address/AddressIcon';
import AddressLink from 'ui/shared/address/AddressLink'; import AddressLink from 'ui/shared/address/AddressLink';
import { TX_INTERNALS_ITEMS } from 'ui/tx/internals/utils';
import TxStatus from 'ui/tx/TxStatus'; import TxStatus from 'ui/tx/TxStatus';
interface Props { interface Props {
...@@ -18,10 +18,12 @@ interface Props { ...@@ -18,10 +18,12 @@ interface Props {
} }
const TxInternalTableItem = ({ type, status, from, to, value, gasLimit }: Props) => { const TxInternalTableItem = ({ type, status, from, to, value, gasLimit }: Props) => {
const typeTitle = TX_INTERNALS_ITEMS.find(({ id }) => id === type)?.title;
return ( return (
<Tr alignItems="top"> <Tr alignItems="top">
<Td> <Td>
<Tag colorScheme="cyan" mr={ 2 }>{ capitalize(type) }</Tag> { typeTitle && <Tag colorScheme="cyan" mr={ 2 }>{ typeTitle }</Tag> }
<TxStatus status={ status }/> <TxStatus status={ status }/>
</Td> </Td>
<Td pr="0"> <Td pr="0">
......
import type { TxInternalsType } from 'types/api/tx';
interface TxInternalsTypeItem {
title: string;
id: TxInternalsType;
}
export const TX_INTERNALS_ITEMS: Array<TxInternalsTypeItem> = [
{ title: 'Call', id: 'call' },
{ title: 'Delegate call', id: 'delegate_call' },
{ title: 'Static call', id: 'static_call' },
{ title: 'Create', id: 'create' },
{ title: 'Create2', id: 'create2' },
{ title: 'Self-destruct', id: 'self_destruct' },
{ title: 'Reward', id: 'reward' },
];
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment