diff --git a/apps/explorer/jest.config.js b/apps/explorer/jest.config.js index 591654a09..fcdba1f33 100644 --- a/apps/explorer/jest.config.js +++ b/apps/explorer/jest.config.js @@ -7,4 +7,5 @@ module.exports = { }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], coverageDirectory: '../../coverage/apps/explorer', + setupFilesAfterEnv: ['./src/app/setupTests.ts'], }; diff --git a/apps/explorer/src/app/components/render-fetched/index.tsx b/apps/explorer/src/app/components/render-fetched/index.tsx index 1e03a2f11..233bc0996 100644 --- a/apps/explorer/src/app/components/render-fetched/index.tsx +++ b/apps/explorer/src/app/components/render-fetched/index.tsx @@ -19,7 +19,9 @@ export const RenderFetched = ({ } if (error) { - return Error: {error}; + return ( + Error retrieving data + ); } return children; diff --git a/apps/explorer/src/app/components/seconds-ago/index.tsx b/apps/explorer/src/app/components/seconds-ago/index.tsx index 6d68ba550..965fd6024 100644 --- a/apps/explorer/src/app/components/seconds-ago/index.tsx +++ b/apps/explorer/src/app/components/seconds-ago/index.tsx @@ -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 ( -
+
{timeAgoInSeconds === 1 ? '1 second' : `${timeAgoInSeconds} seconds`} ago
); diff --git a/apps/explorer/src/app/components/seconds-ago/seconds-ago.spec.tsx b/apps/explorer/src/app/components/seconds-ago/seconds-ago.spec.tsx new file mode 100644 index 000000000..86a05ad41 --- /dev/null +++ b/apps/explorer/src/app/components/seconds-ago/seconds-ago.spec.tsx @@ -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(); + + 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(); + + expect(screen.getByTestId('test-seconds-ago')).toHaveTextContent( + `${secondsToWait} seconds ago` + ); + }); +}); diff --git a/apps/explorer/src/app/components/status-message/index.tsx b/apps/explorer/src/app/components/status-message/index.tsx index 10bc441cc..ae8958270 100644 --- a/apps/explorer/src/app/components/status-message/index.tsx +++ b/apps/explorer/src/app/components/status-message/index.tsx @@ -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

{children}

; + return ( +

+ {children} +

+ ); }; diff --git a/apps/explorer/src/app/components/status-message/status-message.spec.tsx b/apps/explorer/src/app/components/status-message/status-message.spec.tsx new file mode 100644 index 000000000..541b10fbe --- /dev/null +++ b/apps/explorer/src/app/components/status-message/status-message.spec.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@testing-library/react'; +import { StatusMessage } from './index'; + +describe('Status message', () => { + it('should render successfully', () => { + render( + test + ); + expect(screen.getByTestId('status-message-test')).toBeInTheDocument(); + }); +}); diff --git a/apps/explorer/src/app/components/table/table.spec.tsx b/apps/explorer/src/app/components/table/table.spec.tsx new file mode 100644 index 000000000..9ba2e5bd8 --- /dev/null +++ b/apps/explorer/src/app/components/table/table.spec.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; + +import { Table, TableRow, TableHeader, TableCell } from './index'; + +describe('Renders all table components', () => { + render( + + + Title + Content + +
+ ); + + 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( + + + With modifier + +
+ ); + + expect(screen.getByTestId('modifier-test')).toHaveClass('border-white-40'); + }); +}); + +describe('Table header', () => { + it('should accept props i.e. scope="row"', () => { + render( + + + + Test + + +
+ ); + + expect(screen.getByTestId('props-test')).toHaveAttribute('scope'); + }); + + it('should include custom class based on scope="row"', () => { + render( + + + + With scope attribute + + +
+ ); + + expect(screen.getByTestId('scope-class-test')).toHaveClass('text-left'); + }); +}); + +describe('Table cell', () => { + it('should include class based on custom "modifier" prop', () => { + render( + + + + With modifier + + +
+ ); + + expect(screen.getByTestId('modifier-class-test')).toHaveClass('py-4'); + }); +}); diff --git a/apps/explorer/src/app/components/truncate/truncate.spec.tsx b/apps/explorer/src/app/components/truncate/truncate.spec.tsx new file mode 100644 index 000000000..85677d8be --- /dev/null +++ b/apps/explorer/src/app/components/truncate/truncate.spec.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import { TruncateInline } from './truncate'; + +describe('Truncate', () => { + it('should render successfully', () => { + render( + + ); + + 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( + + ); + + 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( + + ); + + expect(screen.getByText(test)).toBeInTheDocument(); + }); +}); diff --git a/apps/explorer/src/app/components/truncate/truncate.tsx b/apps/explorer/src/app/components/truncate/truncate.tsx index 44cf2e2ba..c478c8fe4 100644 --- a/apps/explorer/src/app/components/truncate/truncate.tsx +++ b/apps/explorer/src/app/components/truncate/truncate.tsx @@ -22,6 +22,7 @@ export function TruncateInline({ children, startChars, endChars, + ...props }: TruncateInlineProps) { if (text === null) { return ; @@ -31,6 +32,7 @@ export function TruncateInline({ const wrapperProps = { title: text, className, + ...props, }; if (children !== undefined) { diff --git a/apps/explorer/src/app/components/txs/id/tx-content.tsx b/apps/explorer/src/app/components/txs/id/tx-content.tsx index ed0f0112e..e0ecc669d 100644 --- a/apps/explorer/src/app/components/txs/id/tx-content.tsx +++ b/apps/explorer/src/app/components/txs/id/tx-content.tsx @@ -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 ( + Could not retrieve transaction content + ); } - const { marketId, type, side, size } = JSON.parse(data.Command); - - const displayCode = { - market: marketId, - type, - side, - size, - }; - return ( <> - - - - - +
Type{data.Type}
+ + + Type + + + + +
-

Decoded transaction content

- +

Decoded transaction content

+ ); }; diff --git a/apps/explorer/src/app/components/txs/id/tx-details.tsx b/apps/explorer/src/app/components/txs/id/tx-details.tsx index 2d674ba79..71a9f1915 100644 --- a/apps/explorer/src/app/components/txs/id/tx-details.tsx +++ b/apps/explorer/src/app/components/txs/id/tx-details.tsx @@ -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 ( - - +
+ Hash - {txData.hash} + + {txData.hash} + - {pubKey ? ( - - - - - ) : ( - - - - - )} - {txData.height ? ( - - - - - ) : null} - - - + + + Submitted by + + + + {pubKey} + + + + + Block + + + {txData.height} + + + + + Encoded tnx + + +
Submitted by - {pubKey} - Submitted byAwaiting decoded transaction dataBlock - - {txData.height} - - Encoded tnx{txData.tx}
); diff --git a/apps/explorer/src/app/components/txs/tx-order-type/index.tsx b/apps/explorer/src/app/components/txs/tx-order-type/index.tsx new file mode 100644 index 000000000..4df94080a --- /dev/null +++ b/apps/explorer/src/app/components/txs/tx-order-type/index.tsx @@ -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 ( + + {displayString[orderType] || orderType} + + ); +}; diff --git a/apps/explorer/src/app/components/txs/txs-per-block/index.tsx b/apps/explorer/src/app/components/txs/txs-per-block/index.tsx index 07af94457..d4e4b5555 100644 --- a/apps/explorer/src/app/components/txs/txs-per-block/index.tsx +++ b/apps/explorer/src/app/components/txs/txs-per-block/index.tsx @@ -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" /> - {Type} + + + ); })} diff --git a/apps/explorer/src/app/routes/txs/id/index.tsx b/apps/explorer/src/app/routes/txs/id/index.tsx index ee24647b2..5698bdbe4 100644 --- a/apps/explorer/src/app/routes/txs/id/index.tsx +++ b/apps/explorer/src/app/routes/txs/id/index.tsx @@ -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( `${DATA_SOURCES.tendermintUrl}/tx?hash=${txHash}` ); + const { - state: { data: decodedData }, + state: { data: ceTxData, loading: ceTxLoading, error: ceTxError }, } = useFetch(DATA_SOURCES.chainExplorerUrl, { method: 'POST', body: JSON.stringify({ @@ -25,13 +29,20 @@ const Tx = () => { return (
-

Transaction details

- -

Transaction content

- + Transaction details + + + + + +

Transaction content

+ + +
); }; diff --git a/libs/tailwindcss-config/src/theme.js b/libs/tailwindcss-config/src/theme.js index d093df574..2602a06f8 100644 --- a/libs/tailwindcss-config/src/theme.js +++ b/libs/tailwindcss-config/src/theme.js @@ -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, diff --git a/libs/ui-toolkit/src/components/lozenge/index.ts b/libs/ui-toolkit/src/components/lozenge/index.ts new file mode 100644 index 000000000..df4ba1467 --- /dev/null +++ b/libs/ui-toolkit/src/components/lozenge/index.ts @@ -0,0 +1 @@ +export * from './lozenge'; diff --git a/libs/ui-toolkit/src/components/lozenge/lozenge.spec.tsx b/libs/ui-toolkit/src/components/lozenge/lozenge.spec.tsx new file mode 100644 index 000000000..6da383483 --- /dev/null +++ b/libs/ui-toolkit/src/components/lozenge/lozenge.spec.tsx @@ -0,0 +1,10 @@ +import { render } from '@testing-library/react'; + +import { Lozenge } from './lozenge'; + +describe('Lozenge', () => { + it('should render successfully', () => { + const { baseElement } = render(Lozenge); + expect(baseElement).toBeTruthy(); + }); +}); diff --git a/libs/ui-toolkit/src/components/lozenge/lozenge.stories.tsx b/libs/ui-toolkit/src/components/lozenge/lozenge.stories.tsx new file mode 100644 index 000000000..6ceefccf6 --- /dev/null +++ b/libs/ui-toolkit/src/components/lozenge/lozenge.stories.tsx @@ -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; + +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', +}; diff --git a/libs/ui-toolkit/src/components/lozenge/lozenge.tsx b/libs/ui-toolkit/src/components/lozenge/lozenge.tsx new file mode 100644 index 000000000..e12fd4020 --- /dev/null +++ b/libs/ui-toolkit/src/components/lozenge/lozenge.tsx @@ -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 ( + + {children} + + {details && {details}} + + ); +}; diff --git a/libs/ui-toolkit/src/index.ts b/libs/ui-toolkit/src/index.ts index 47e9f56b7..638072c6b 100644 --- a/libs/ui-toolkit/src/index.ts +++ b/libs/ui-toolkit/src/index.ts @@ -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';