Commit 365249f1 authored by Igor Stuev's avatar Igor Stuev Committed by GitHub

Merge branch 'main' into quick-search

parents 7a6ae38d 0409b908
......@@ -18,6 +18,8 @@ docker run -p 3000:3000 --env-file <path-to-your-env-file> ghcr.io/blockscout/fr
Alternatively, you can build your own docker image and run your app from that. Please follow this [guide](./docs/CUSTOM_BUILD.md).
For more information on migrating from the previous frontend, please see the [frontend migration docs](https://docs.blockscout.com/for-developers/frontend-migration).
## Contributing
See our [Contribution guide](./docs/CONTRIBUTING.md) for pull request protocol. We expect contributors to follow our [code of conduct](./CODE_OF_CONDUCT.md) when submitting code or comments.
......@@ -25,7 +27,8 @@ See our [Contribution guide](./docs/CONTRIBUTING.md) for pull request protocol.
## Resources
- [App ENVs list](./docs/ENVS.md)
- [Contribution guide](./docs/CONTRIBUTING.md)
- [Making custom build](./docs/CUSTOM_BUILD.md)
- [Making a custom build](./docs/CUSTOM_BUILD.md)
- [Frontend migration guide](https://docs.blockscout.com/for-developers/frontend-migration)
## License
......
......@@ -30,7 +30,7 @@ const TransactionTagListItem = ({ item, isLoading, onEditClick, onDeleteClick }:
<TxEntity
hash={ item.transaction_hash }
isLoading={ isLoading }
withCopy
noCopy={ false }
fontWeight={ 600 }
maxW="100%"
/>
......
......@@ -32,8 +32,8 @@ const TransactionTagTableItem = ({ item, isLoading, onEditClick, onDeleteClick }
<TxEntity
hash={ item.transaction_hash }
isLoading={ isLoading }
noCopy={ false }
fontWeight={ 600 }
withCopy
/>
</Td>
<Td>
......
......@@ -19,6 +19,9 @@ interface Props {
maxW: StyleProps['maxW'];
}
/**
* @deprecated use `ui/shared/entities/token/**` instead
*/
const TokenSnippet = ({ data, className, logoSize = 6, isDisabled, hideSymbol, hideIcon, isLoading, maxW }: Props) => {
const withSymbol = data && data.symbol && !hideSymbol;
......
......@@ -5,6 +5,9 @@ type Props = {
className?: string;
}
/**
* @deprecated use `ui/shared/entities/**` instead
*/
const AddressContractIcon = ({ className }: Props) => {
const bgColor = useColorModeValue('gray.200', 'gray.600');
const color = useColorModeValue('gray.400', 'gray.200');
......
......@@ -12,6 +12,9 @@ type Props = {
isLoading?: boolean;
}
/**
* @deprecated use `ui/shared/entities/**` instead
*/
const AddressIcon = ({ address, className, isLoading }: Props) => {
if (isLoading) {
return <Skeleton boxSize={ 6 } className={ className } borderRadius="full" flexShrink={ 0 }/>;
......
......@@ -36,6 +36,9 @@ type AddressTokenProps = {
type Props = CommonProps & (AddressTokenTxProps | AddressTokenProps);
/**
* @deprecated use `ui/shared/entities/**` instead
*/
const AddressLink = (props: Props) => {
const { alias, type, className, truncation = 'dynamic', hash, fontWeight, target = '_self', isDisabled, isLoading } = props;
const isMobile = useIsMobile();
......
......@@ -95,6 +95,19 @@ test('external link', async({ mount }) => {
await expect(component).toHaveScreenshot();
});
test('no link', async({ mount }) => {
const component = await mount(
<TestApp>
<AddressEntity
address={ addressMock.withoutName }
noLink
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('customization', async({ mount }) => {
const component = await mount(
<TestApp>
......
......@@ -14,9 +14,7 @@ import * as EntityBase from 'ui/shared/entities/base/components';
import { getIconProps } from '../base/utils';
interface LinkProps extends Pick<EntityProps, 'className' | 'address' | 'onClick' | 'isLoading' | 'isExternal' | 'href' | 'query'> {
children: React.ReactNode;
}
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'address'>;
const Link = chakra((props: LinkProps) => {
const defaultHref = route({ pathname: '/address/[hash]', query: { ...props.query, hash: props.address.hash } });
......@@ -101,7 +99,6 @@ const Copy = (props: CopyProps) => {
<EntityBase.Copy
{ ...props }
text={ props.address.hash }
withCopy={ props.withCopy ?? true }
/>
);
};
......
import { chakra } from '@chakra-ui/react';
import _omit from 'lodash/omit';
import React from 'react';
import { route } from 'nextjs-routes';
import * as AddressEntity from './AddressEntity';
interface Props extends AddressEntity.EntityProps {
tokenHash: string;
}
const AddressEntityWithTokenFilter = (props: Props) => {
const linkProps = _omit(props, [ 'className' ]);
const partsProps = _omit(props, [ 'className', 'onClick' ]);
const defaultHref = route({
pathname: '/address/[hash]',
query: {
...props.query,
hash: props.address.hash,
tab: 'token_transfers',
token: props.tokenHash,
scroll_to_tabs: 'true',
},
});
return (
<AddressEntity.Container className={ props.className }>
<AddressEntity.Icon { ...partsProps }/>
<AddressEntity.Link
{ ...linkProps }
href={ props.href ?? defaultHref }
>
<AddressEntity.Content { ...partsProps }/>
</AddressEntity.Link>
</AddressEntity.Container>
);
};
export default chakra(AddressEntityWithTokenFilter);
......@@ -16,14 +16,15 @@ export type Truncation = 'constant' | 'dynamic' | 'tail' | 'none';
export interface EntityBaseProps {
className?: string;
isLoading?: boolean;
href?: string;
iconSize?: IconSize;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
isExternal?: boolean;
href?: string;
query?: Record<string, string>;
isLoading?: boolean;
noCopy?: boolean;
noIcon?: boolean;
withCopy?: boolean;
noLink?: boolean;
onClick?: (event: React.MouseEvent<HTMLAnchorElement>) => void;
query?: Record<string, string>;
tailLength?: number;
truncation?: Truncation;
}
......@@ -32,7 +33,7 @@ export interface ContainerBaseProps extends Pick<EntityBaseProps, 'className'> {
children: React.ReactNode;
}
const Container = ({ className, children }: ContainerBaseProps) => {
const Container = chakra(({ className, children }: ContainerBaseProps) => {
return (
<Flex
className={ className }
......@@ -42,23 +43,31 @@ const Container = ({ className, children }: ContainerBaseProps) => {
{ children }
</Flex>
);
};
});
export interface LinkBaseProps extends Pick<EntityBaseProps, 'className' | 'onClick' | 'isLoading' | 'isExternal' | 'href'> {
export interface LinkBaseProps extends Pick<EntityBaseProps, 'className' | 'onClick' | 'isLoading' | 'isExternal' | 'href' | 'noLink' | 'query'> {
children: React.ReactNode;
}
const Link = chakra(({ isLoading, children, isExternal, onClick, href }: LinkBaseProps) => {
const Link = chakra(({ isLoading, children, isExternal, onClick, href, noLink }: LinkBaseProps) => {
const styles = {
display: 'inline-flex',
alignItems: 'center',
minWidth: 0, // for content truncation - https://css-tricks.com/flexbox-truncated-text/
};
if (noLink) {
return <Skeleton isLoaded={ !isLoading } { ...styles }>{ children }</Skeleton>;
}
const Component = isExternal ? LinkExternal : LinkInternal;
return (
<Component
{ ...styles }
href={ href }
isLoading={ isLoading }
onClick={ onClick }
display="inline-flex"
alignItems="center"
minWidth={ 0 } // for content truncation - https://css-tricks.com/flexbox-truncated-text/
>
{ children }
</Component>
......@@ -131,10 +140,10 @@ const Content = chakra(({ className, isLoading, asProp, text, truncation = 'dyna
);
});
export type CopyBaseProps = Pick<CopyToClipboardProps, 'isLoading' | 'text'> & Pick<EntityBaseProps, 'withCopy'>;
export type CopyBaseProps = Pick<CopyToClipboardProps, 'isLoading' | 'text'> & Pick<EntityBaseProps, 'noCopy'>;
const Copy = (props: CopyBaseProps) => {
if (!props.withCopy) {
if (props.noCopy) {
return null;
}
......
import { Box } from '@chakra-ui/react';
import { test, expect } from '@playwright/experimental-ct-react';
import React from 'react';
import * as tokenMock from 'mocks/tokens/tokenInfo';
import TestApp from 'playwright/TestApp';
import TokenEntity from './TokenEntity';
const iconSizes = [ 'md', 'lg' ];
test.use({ viewport: { width: 300, height: 100 } });
test.describe('icon size', () => {
iconSizes.forEach((size) => {
test(size, async({ mount }) => {
const component = await mount(
<TestApp>
<TokenEntity
token={ tokenMock.tokenInfo }
iconSize={ size }
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
});
});
test('with logo, long name and symbol', async({ page, mount }) => {
const LOGO_URL = 'https://example.com/logo.png';
await page.route(LOGO_URL, (route) => {
return route.fulfill({
status: 200,
path: './playwright/mocks/image_s.jpg',
});
});
await mount(
<TestApp>
<TokenEntity
token={{
name: 'This token is the best token ever',
symbol: 'DUCK DUCK DUCK',
address: tokenMock.tokenInfo.address,
icon_url: LOGO_URL,
}}
/>
</TestApp>,
);
await page.getByText(/this/i).hover();
await expect(page).toHaveScreenshot();
await page.getByText(/duc/i).hover();
await expect(page).toHaveScreenshot();
});
test('loading', async({ mount }) => {
const component = await mount(
<TestApp>
<TokenEntity
token={ tokenMock.tokenInfo }
isLoading
/>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
test('customization', async({ mount }) => {
const component = await mount(
<TestApp>
<Box
borderWidth="1px"
borderColor="orange.500"
>
<TokenEntity
token={ tokenMock.tokenInfo }
p={ 2 }
maxW="200px"
borderWidth="1px"
borderColor="blue.700"
/>
</Box>
</TestApp>,
);
await expect(component).toHaveScreenshot();
});
import type { As } from '@chakra-ui/react';
import { Image, Skeleton, chakra } from '@chakra-ui/react';
import _omit from 'lodash/omit';
import React from 'react';
import type { TokenInfo } from 'types/api/token';
import { route } from 'nextjs-routes';
import * as EntityBase from 'ui/shared/entities/base/components';
import TokenLogoPlaceholder from 'ui/shared/TokenLogoPlaceholder';
import TruncatedTextTooltip from 'ui/shared/TruncatedTextTooltip';
import { getIconProps } from '../base/utils';
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'token'>;
const Link = chakra((props: LinkProps) => {
const defaultHref = route({ pathname: '/token/[hash]', query: { ...props.query, hash: props.token.address } });
return (
<EntityBase.Link
{ ...props }
href={ props.href ?? defaultHref }
>
{ props.children }
</EntityBase.Link>
);
});
type IconProps = Pick<EntityProps, 'token' | 'isLoading' | 'iconSize' | 'noIcon'> & {
asProp?: As;
};
const Icon = (props: IconProps) => {
if (props.noIcon) {
return null;
}
const styles = {
...getIconProps(props.iconSize),
marginRight: 2,
borderRadius: 'base',
};
if (props.isLoading) {
return <Skeleton { ...styles } flexShrink={ 0 }/>;
}
return (
<Image
{ ...styles }
src={ props.token.icon_url ?? undefined }
alt={ `${ props.token.name || 'token' } logo` }
fallback={ <TokenLogoPlaceholder { ...styles }/> }
/>
);
};
type ContentProps = Omit<EntityBase.ContentBaseProps, 'text'> & Pick<EntityProps, 'token'>;
const Content = chakra((props: ContentProps) => {
const name = props.token.name ?? 'Unnamed token';
return (
<TruncatedTextTooltip label={ name }>
<Skeleton
isLoaded={ !props.isLoading }
display="inline-block"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
height="fit-content"
>
{ name }
</Skeleton>
</TruncatedTextTooltip>
);
});
type SymbolProps = Pick<EntityProps, 'token' | 'isLoading' | 'noSymbol'>;
const Symbol = (props: SymbolProps) => {
const symbol = props.token.symbol;
if (!symbol || props.noSymbol) {
return null;
}
return (
<Skeleton
isLoaded={ !props.isLoading }
display="inline-flex"
alignItems="center"
maxW="20%"
ml={ 2 }
color="text_secondary"
>
<div>(</div>
<TruncatedTextTooltip label={ symbol }>
<chakra.span
display="inline-block"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
height="fit-content"
>
{ symbol }
</chakra.span>
</TruncatedTextTooltip>
<div>)</div>
</Skeleton>
);
};
type CopyProps = Omit<EntityBase.CopyBaseProps, 'text'> & Pick<EntityProps, 'token'>;
const Copy = (props: CopyProps) => {
return (
<EntityBase.Copy
{ ...props }
text={ props.token.address }
/>
);
};
const Container = EntityBase.Container;
export interface EntityProps extends EntityBase.EntityBaseProps {
token: Pick<TokenInfo, 'address' | 'icon_url' | 'name' | 'symbol'>;
noSymbol?: boolean;
}
const TokenEntity = (props: EntityProps) => {
const linkProps = _omit(props, [ 'className' ]);
const partsProps = _omit(props, [ 'className', 'onClick' ]);
return (
<Container className={ props.className } w="100%">
<Icon { ...partsProps }/>
<Link { ...linkProps }>
<Content { ...partsProps }/>
</Link>
<Symbol { ...partsProps }/>
<Copy { ...partsProps }/>
</Container>
);
};
export default React.memo(chakra(TokenEntity));
export {
Container,
Link,
Icon,
Content,
Copy,
};
......@@ -58,7 +58,7 @@ test('with copy +@dark-mode', async({ mount }) => {
<TestApp>
<TxEntity
hash={ hash }
withCopy
noCopy={ false }
/>
</TestApp>,
);
......
......@@ -8,9 +8,7 @@ import { route } from 'nextjs-routes';
import transactionIcon from 'icons/transactions_slim.svg';
import * as EntityBase from 'ui/shared/entities/base/components';
interface LinkProps extends Pick<EntityProps, 'className' | 'hash' | 'onClick' | 'isLoading' | 'isExternal' | 'href'> {
children: React.ReactNode;
}
type LinkProps = EntityBase.LinkBaseProps & Pick<EntityProps, 'hash'>;
const Link = chakra((props: LinkProps) => {
const defaultHref = route({ pathname: '/tx/[hash]', query: { hash: props.hash } });
......@@ -56,6 +54,8 @@ const Copy = (props: CopyProps) => {
<EntityBase.Copy
{ ...props }
text={ props.hash }
// by default we don't show copy icon, maybe this should be revised
noCopy={ props.noCopy ?? true }
/>
);
};
......
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