Merge pull request #85 from vegaprotocol/feat/40-tx-styling

Feat/40 Styles for transaction and transaction details page
This commit is contained in:
Sam Keen 2022-03-18 16:08:22 +00:00 committed by GitHub
commit c888ca1087
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 410 additions and 67 deletions

View File

@ -7,4 +7,5 @@ module.exports = {
},
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
coverageDirectory: '../../coverage/apps/explorer',
setupFilesAfterEnv: ['./src/app/setupTests.ts'],
};

View File

@ -19,7 +19,9 @@ export const RenderFetched = ({
}
if (error) {
return <StatusMessage className={className}>Error: {error}</StatusMessage>;
return (
<StatusMessage className={className}>Error retrieving data</StatusMessage>
);
}
return children;

View File

@ -4,7 +4,7 @@ interface SecondsAgoProps {
date: string | undefined;
}
export const SecondsAgo = ({ date }: SecondsAgoProps) => {
export const SecondsAgo = ({ date, ...props }: SecondsAgoProps) => {
const [now, setNow] = useState(Date.now());
useEffect(() => {
@ -18,10 +18,11 @@ export const SecondsAgo = ({ date }: SecondsAgoProps) => {
return <>Date unknown</>;
}
const timeAgoInSeconds = Math.floor((now - new Date(date).getTime()) / 1000);
return (
<div>
<div {...props}>
{timeAgoInSeconds === 1 ? '1 second' : `${timeAgoInSeconds} seconds`} ago
</div>
);

View File

@ -0,0 +1,36 @@
import { render, screen, act } from '@testing-library/react';
import { SecondsAgo } from './index';
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
});
describe('Seconds ago', () => {
it('should render successfully', () => {
const dateInString = new Date().toString();
render(<SecondsAgo data-testid="test-seconds-ago" date={dateInString} />);
expect(screen.getByTestId('test-seconds-ago')).toBeInTheDocument();
});
it('should show the correct amount of seconds ago', () => {
const secondsToWait = 10;
const dateInString = new Date().toString();
act(() => {
jest.advanceTimersByTime(secondsToWait * 1000);
});
jest.runOnlyPendingTimers();
render(<SecondsAgo data-testid="test-seconds-ago" date={dateInString} />);
expect(screen.getByTestId('test-seconds-ago')).toHaveTextContent(
`${secondsToWait} seconds ago`
);
});
});

View File

@ -6,7 +6,15 @@ interface StatusMessageProps {
className?: string;
}
export const StatusMessage = ({ children, className }: StatusMessageProps) => {
export const StatusMessage = ({
children,
className,
...props
}: StatusMessageProps) => {
const classes = classnames('font-alpha text-h4 mb-28', className);
return <h3 className={classes}>{children}</h3>;
return (
<h3 className={classes} {...props}>
{children}
</h3>
);
};

View File

@ -0,0 +1,11 @@
import { render, screen } from '@testing-library/react';
import { StatusMessage } from './index';
describe('Status message', () => {
it('should render successfully', () => {
render(
<StatusMessage data-testid="status-message-test">test</StatusMessage>
);
expect(screen.getByTestId('status-message-test')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,79 @@
import { render, screen } from '@testing-library/react';
import { Table, TableRow, TableHeader, TableCell } from './index';
describe('Renders all table components', () => {
render(
<Table data-testid="test-table">
<TableRow data-testid="test-tr">
<TableHeader data-testid="test-th">Title</TableHeader>
<TableCell data-testid="test-td">Content</TableCell>
</TableRow>
</Table>
);
expect(screen.getByTestId('test-table')).toBeInTheDocument();
expect(screen.getByTestId('test-tr')).toBeInTheDocument();
expect(screen.getByTestId('test-th')).toHaveTextContent('Title');
expect(screen.getByTestId('test-td')).toHaveTextContent('Content');
});
describe('Table row', () => {
it('should include classes based on custom "modifier" prop', () => {
render(
<Table>
<TableRow data-testid="modifier-test" modifier="bordered">
<TableCell>With modifier</TableCell>
</TableRow>
</Table>
);
expect(screen.getByTestId('modifier-test')).toHaveClass('border-white-40');
});
});
describe('Table header', () => {
it('should accept props i.e. scope="row"', () => {
render(
<Table>
<TableRow>
<TableHeader data-testid="props-test" scope="row">
Test
</TableHeader>
</TableRow>
</Table>
);
expect(screen.getByTestId('props-test')).toHaveAttribute('scope');
});
it('should include custom class based on scope="row"', () => {
render(
<Table>
<TableRow>
<TableHeader data-testid="scope-class-test" scope="row">
With scope attribute
</TableHeader>
</TableRow>
</Table>
);
expect(screen.getByTestId('scope-class-test')).toHaveClass('text-left');
});
});
describe('Table cell', () => {
it('should include class based on custom "modifier" prop', () => {
render(
<Table>
<TableRow>
<TableCell data-testid="modifier-class-test" modifier="bordered">
With modifier
</TableCell>
</TableRow>
</Table>
);
expect(screen.getByTestId('modifier-class-test')).toHaveClass('py-4');
});
});

View File

@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import { TruncateInline } from './truncate';
describe('Truncate', () => {
it('should render successfully', () => {
render(
<TruncateInline data-testid="truncate-test" text={'Texty McTextFace'} />
);
expect(screen.getByTestId('truncate-test')).toBeInTheDocument();
});
it('it truncates as expected', () => {
const test = 'randomstringblahblah';
const startChars = 3;
const endChars = 3;
const expectedString = `${test.slice(0, startChars)}${test.slice(
-endChars
)}`;
render(
<TruncateInline text={test} startChars={startChars} endChars={endChars} />
);
expect(screen.getByText(expectedString)).toBeInTheDocument();
});
it("it doesn't truncate if the string is too short", () => {
const test = 'randomstringblahblah';
const startChars = test.length;
const endChars = test.length;
render(
<TruncateInline text={test} startChars={startChars} endChars={endChars} />
);
expect(screen.getByText(test)).toBeInTheDocument();
});
});

View File

@ -22,6 +22,7 @@ export function TruncateInline({
children,
startChars,
endChars,
...props
}: TruncateInlineProps) {
if (text === null) {
return <span data-testid="empty-truncation" />;
@ -31,6 +32,7 @@ export function TruncateInline({
const wrapperProps = {
title: text,
className,
...props,
};
if (children !== undefined) {

View File

@ -1,6 +1,8 @@
import { ChainExplorerTxResponse } from '../../../routes/types/chain-explorer-response';
import { Table } from '../../table';
import { SyntaxHighlighter } from '../../syntax-highlighter';
import { Table, TableRow, TableHeader, TableCell } from '../../table';
import { TxOrderType } from '../tx-order-type';
import { StatusMessage } from '../../status-message';
interface TxContentProps {
data: ChainExplorerTxResponse | undefined;
@ -8,29 +10,26 @@ interface TxContentProps {
export const TxContent = ({ data }: TxContentProps) => {
if (!data?.Command) {
return <>Awaiting decoded transaction data</>;
return (
<StatusMessage>Could not retrieve transaction content</StatusMessage>
);
}
const { marketId, type, side, size } = JSON.parse(data.Command);
const displayCode = {
market: marketId,
type,
side,
size,
};
return (
<>
<Table>
<tr>
<td>Type</td>
<td>{data.Type}</td>
</tr>
<Table className="mb-12">
<TableRow modifier="bordered">
<TableHeader scope="row" className="w-[160px]">
Type
</TableHeader>
<TableCell modifier="bordered">
<TxOrderType orderType={data.Type} />
</TableCell>
</TableRow>
</Table>
<h3>Decoded transaction content</h3>
<SyntaxHighlighter data={displayCode} />
<h3 className="font-mono mb-8">Decoded transaction content</h3>
<SyntaxHighlighter data={JSON.parse(data.Command)} />
</>
);
};

View File

@ -1,50 +1,63 @@
import { Link } from 'react-router-dom';
import { Routes } from '../../../routes/router-config';
import { Result } from '../../../routes/txs/tendermint-transaction-response.d';
import { Table, TableRow, TableCell } from '../../table';
import { Table, TableRow, TableCell, TableHeader } from '../../table';
import { TruncateInline } from '../../truncate/truncate';
interface TxDetailsProps {
txData: Result | undefined;
pubKey: string | undefined;
className?: string;
}
export const TxDetails = ({ txData, pubKey }: TxDetailsProps) => {
const truncateLength = 30;
export const TxDetails = ({ txData, pubKey, className }: TxDetailsProps) => {
if (!txData) {
return <>Awaiting Tendermint transaction details</>;
}
return (
<Table>
<TableRow>
<Table className={className}>
<TableRow modifier="bordered">
<TableCell>Hash</TableCell>
<TableCell data-testid="hash">{txData.hash}</TableCell>
<TableCell modifier="bordered" data-testid="hash">
{txData.hash}
</TableCell>
</TableRow>
{pubKey ? (
<TableRow>
<td>Submitted by</td>
<td data-testid="submitted-by">
<Link to={`/${Routes.PARTIES}/${pubKey}`}>{pubKey}</Link>
</td>
</TableRow>
) : (
<TableRow>
<td>Submitted by</td>
<td>Awaiting decoded transaction data</td>
</TableRow>
)}
{txData.height ? (
<TableRow>
<td>Block</td>
<td data-testid="block">
<Link to={`/${Routes.BLOCKS}/${txData.height}`}>
{txData.height}
</Link>
</td>
</TableRow>
) : null}
<TableRow>
<td>Encoded tnx</td>
<td data-testid="encoded-tnx">{txData.tx}</td>
<TableRow modifier="bordered">
<TableHeader scope="row" className="w-[160px]">
Submitted by
</TableHeader>
<TableCell modifier="bordered" data-testid="submitted-by">
<Link
className="text-vega-yellow"
to={`/${Routes.PARTIES}/${pubKey}`}
>
{pubKey}
</Link>
</TableCell>
</TableRow>
<TableRow modifier="bordered">
<TableCell>Block</TableCell>
<TableCell modifier="bordered" data-testid="block">
<Link
className="text-vega-yellow"
to={`/${Routes.BLOCKS}/${txData.height}`}
>
{txData.height}
</Link>
</TableCell>
</TableRow>
<TableRow modifier="bordered">
<TableCell>Encoded tnx</TableCell>
<TableCell modifier="bordered" data-testid="encoded-tnx">
<TruncateInline
text={txData.tx}
startChars={truncateLength}
endChars={truncateLength}
/>
</TableCell>
</TableRow>
</Table>
);

View File

@ -0,0 +1,43 @@
import { Lozenge } from '@vegaprotocol/ui-toolkit';
interface TxOrderTypeProps {
orderType: string;
className?: string;
}
interface StringMap {
[key: string]: string;
}
// Using https://github.com/vegaprotocol/protos/blob/e0f646ce39aab1fc66a9200ceec0262306d3beb3/commands/transaction.go#L93 as a reference
const displayString: StringMap = {
OrderSubmission: 'Order Submission',
OrderCancellation: 'Order Cancellation',
OrderAmendment: 'Order Amendment',
VoteSubmission: 'Vote Submission',
WithdrawSubmission: 'Withdraw Submission',
LiquidityProvisionSubmission: 'Liquidity Provision',
LiquidityProvisionCancellation: 'Liquidity Cancellation',
LiquidityProvisionAmendment: 'Liquidity Amendment',
ProposalSubmission: 'Governance Proposal',
AnnounceNode: 'Node Announcement',
NodeVote: 'Node Vote',
NodeSignature: 'Node Signature',
ChainEvent: 'Chain Event',
OracleDataSubmission: 'Oracle Data',
DelegateSubmission: 'Delegation',
UndelegateSubmission: 'Undelegation',
KeyRotateSubmission: 'Key Rotation',
StateVariableProposal: 'State Variable Proposal',
Transfer: 'Transfer',
CancelTransfer: 'Cancel Transfer',
ValidatorHeartbeat: 'Validator Heartbeat',
};
export const TxOrderType = ({ orderType, className }: TxOrderTypeProps) => {
return (
<Lozenge className={className}>
{displayString[orderType] || orderType}
</Lozenge>
);
};

View File

@ -5,6 +5,7 @@ import { DATA_SOURCES } from '../../../config';
import { Link } from 'react-router-dom';
import { RenderFetched } from '../../render-fetched';
import { TruncateInline } from '../../truncate/truncate';
import { TxOrderType } from '../tx-order-type';
interface TxsPerBlockProps {
blockHeight: string | undefined;
@ -62,7 +63,9 @@ export const TxsPerBlock = ({ blockHeight }: TxsPerBlockProps) => {
className="font-mono"
/>
</td>
<td>{Type}</td>
<td>
<TxOrderType className="mb-4" orderType={Type} />
</td>
</tr>
);
})}

View File

@ -1,20 +1,24 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { TxContent, TxDetails } from '../../../components/txs';
import { DATA_SOURCES } from '../../../config';
import useFetch from '../../../hooks/use-fetch';
import { ChainExplorerTxResponse } from '../../types/chain-explorer-response';
import { TendermintTransactionResponse } from '../tendermint-transaction-response.d';
import { ChainExplorerTxResponse } from '../../types/chain-explorer-response';
import { DATA_SOURCES } from '../../../config';
import { TxContent, TxDetails } from '../../../components/txs';
import { RouteTitle } from '../../../components/route-title';
import { RenderFetched } from '../../../components/render-fetched';
const Tx = () => {
const { txHash } = useParams<{ txHash: string }>();
const {
state: { data: transactionData },
state: { data: tTxData, loading: tTxLoading, error: tTxError },
} = useFetch<TendermintTransactionResponse>(
`${DATA_SOURCES.tendermintUrl}/tx?hash=${txHash}`
);
const {
state: { data: decodedData },
state: { data: ceTxData, loading: ceTxLoading, error: ceTxError },
} = useFetch<ChainExplorerTxResponse>(DATA_SOURCES.chainExplorerUrl, {
method: 'POST',
body: JSON.stringify({
@ -25,13 +29,20 @@ const Tx = () => {
return (
<section>
<h1>Transaction details</h1>
<TxDetails
txData={transactionData?.result}
pubKey={decodedData?.PubKey}
/>
<h2>Transaction content</h2>
<TxContent data={decodedData} />
<RouteTitle>Transaction details</RouteTitle>
<RenderFetched error={tTxError} loading={tTxLoading}>
<TxDetails
className="mb-28"
txData={tTxData?.result}
pubKey={ceTxData?.PubKey}
/>
</RenderFetched>
<h2 className="text-h4 uppercase mb-16">Transaction content</h2>
<RenderFetched error={ceTxError} loading={ceTxLoading}>
<TxContent data={ceTxData} />
</RenderFetched>
</section>
);
};

View File

@ -46,6 +46,7 @@ module.exports = {
prompt: '#EDFF22',
success: '#26FF8A',
help: '#494949',
highlight: '#E5E5E5',
},
'intent-background': {
danger: '#9E0025', // for white text
@ -93,6 +94,14 @@ module.exports = {
1: '1px',
4: '4px',
},
borderRadius: {
none: '0',
sm: '0.125rem',
DEFAULT: '0.225rem',
md: '0.3rem',
lg: '0.5rem',
full: '9999px',
},
fontFamily: {
mono: defaultTheme.fontFamily.mono,
serif: defaultTheme.fontFamily.serif,

View File

@ -0,0 +1 @@
export * from './lozenge';

View File

@ -0,0 +1,10 @@
import { render } from '@testing-library/react';
import { Lozenge } from './lozenge';
describe('Lozenge', () => {
it('should render successfully', () => {
const { baseElement } = render(<Lozenge>Lozenge</Lozenge>);
expect(baseElement).toBeTruthy();
});
});

View File

@ -0,0 +1,36 @@
import { Story, Meta } from '@storybook/react';
import { Lozenge } from './lozenge';
export default {
component: Lozenge,
title: 'Lozenge',
} as Meta;
const Template: Story = (args) => <Lozenge {...args}>lozenge</Lozenge>;
export const Default = Template.bind({});
export const WithDetails = Template.bind({});
WithDetails.args = {
details: 'details text',
};
export const Highlight = Template.bind({});
Highlight.args = {
variant: 'highlight',
};
export const Success = Template.bind({});
Success.args = {
variant: 'success',
};
export const Warning = Template.bind({});
Warning.args = {
variant: 'warning',
};
export const Danger = Template.bind({});
Danger.args = {
variant: 'danger',
};

View File

@ -0,0 +1,38 @@
import { ReactNode } from 'react';
import classNames from 'classnames';
interface LozengeProps {
children: ReactNode;
variant?: 'success' | 'warning' | 'danger' | 'highlight';
className?: string;
details?: string;
}
const getWrapperClasses = (className: LozengeProps['className']) => {
return classNames('inline-flex items-center gap-4', className);
};
const getLozengeClasses = (variant: LozengeProps['variant']) => {
return classNames(['rounded-md', 'font-mono', 'leading-none', 'p-4'], {
'bg-intent-success text-black': variant === 'success',
'bg-intent-danger text-white': variant === 'danger',
'bg-intent-warning text-black': variant === 'warning',
'bg-intent-highlight text-black': variant === 'highlight',
'bg-intent-help text-white': !variant,
});
};
export const Lozenge = ({
children,
variant,
className,
details,
}: LozengeProps) => {
return (
<span className={getWrapperClasses(className)}>
<span className={getLozengeClasses(variant)}>{children}</span>
{details && <span>{details}</span>}
</span>
);
};

View File

@ -10,6 +10,7 @@ export { FormGroup } from './components/form-group';
export { Icon } from './components/icon';
export { Input } from './components/input';
export { InputError } from './components/input-error';
export { Lozenge } from './components/lozenge';
export { Loader } from './components/loader';
export { Select } from './components/select';
export { Splash } from './components/splash';