Feat/1416 tx detail (#1953)

* chore(explorer): add new vega colours to tw config

* feat(explorer): add new nested data list component

* feat(explorer): abstract page header with copy to new component

* feat(explorer): update styles tx details page

* fix(explorer): linting errors

* fix(explorer): fix txs type

* fix(explorer): fix strong typing

* fix(explorer): fix styling error for e2e test
This commit is contained in:
Elmar 2022-11-07 15:36:39 +00:00 committed by GitHub
parent da99e731fa
commit 5a764d190f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 454 additions and 110 deletions

View File

@ -87,7 +87,7 @@ context('Asset page', { tags: '@regression' }, function () {
const whiteThemeSelectedMenuOptionColor = 'rgb(255, 7, 127)';
const whiteThemeJsonFieldBackColor = 'rgb(255, 255, 255)';
const whiteThemeSideMenuBackgroundColor = 'rgb(255, 255, 255)';
const darkThemeSelectedMenuOptionColor = 'rgb(223, 255, 11)';
const darkThemeSelectedMenuOptionColor = 'rgb(215, 251, 80)';
const darkThemeJsonFieldBackColor = 'rgb(38, 38, 38)';
const darkThemeSideMenuBackgroundColor = 'rgb(0, 0, 0)';
const themeSwitcher = '[data-testid="theme-switcher"]';

View File

@ -205,7 +205,7 @@ context('Network parameters page', { tags: '@smoke' }, function () {
const whiteThemeSelectedMenuOptionColor = 'rgb(255, 7, 127)';
const whiteThemeJsonFieldBackColor = 'rgb(255, 255, 255)';
const whiteThemeSideMenuBackgroundColor = 'rgb(255, 255, 255)';
const darkThemeSelectedMenuOptionColor = 'rgb(223, 255, 11)';
const darkThemeSelectedMenuOptionColor = 'rgb(215, 251, 80)';
const darkThemeJsonFieldBackColor = 'rgb(38, 38, 38)';
const darkThemeSideMenuBackgroundColor = 'rgb(0, 0, 0)';
const themeSwitcher = '[data-testid="theme-switcher"]';

View File

@ -161,7 +161,7 @@ context('Parties page', { tags: '@regression' }, function () {
const whiteThemeSelectedMenuOptionColor = 'rgb(255, 7, 127)';
const whiteThemeJsonFieldBackColor = 'rgb(255, 255, 255)';
const whiteThemeSideMenuBackgroundColor = 'rgb(255, 255, 255)';
const darkThemeSelectedMenuOptionColor = 'rgb(223, 255, 11)';
const darkThemeSelectedMenuOptionColor = 'rgb(215, 251, 80)';
const darkThemeJsonFieldBackColor = 'rgb(38, 38, 38)';
const darkThemeSideMenuBackgroundColor = 'rgb(0, 0, 0)';
const themeSwitcher = '[data-testid="theme-switcher"]';

View File

@ -216,7 +216,7 @@ context('Validator page', { tags: '@smoke' }, function () {
const whiteThemeSelectedMenuOptionColor = 'rgb(255, 7, 127)';
const whiteThemeJsonFieldBackColor = 'rgb(255, 255, 255)';
const whiteThemeSideMenuBackgroundColor = 'rgb(255, 255, 255)';
const darkThemeSelectedMenuOptionColor = 'rgb(223, 255, 11)';
const darkThemeSelectedMenuOptionColor = 'rgb(215, 251, 80)';
const darkThemeJsonFieldBackColor = 'rgb(38, 38, 38)';
const darkThemeSideMenuBackgroundColor = 'rgb(0, 0, 0)';
const themeSwitcher = '[data-testid="theme-switcher"]';

View File

@ -0,0 +1 @@
export * from './nested-data-list';

View File

@ -0,0 +1,110 @@
import { render, waitFor } from '@testing-library/react';
import {
BORDER_COLOURS,
NestedDataList,
sortNestedDataByChildren,
} from './nested-data-list';
import userEvent from '@testing-library/user-event';
const mockData = {
nonce: '5980890939790185837',
validatorHeartbeat: {
nodeId: 'e07d2cd299659590c16ec1cc1c69936ad747083c379ea6b6cfeaa6e22c8af0cb',
ethereumSignature: {
value:
'8b157cb43a716ad541065f643e38cd92d7b1857c7beeb7d81e878be4f9a48f5a346be973e478eba3c18907888678625ba6700b086f65406d5ce0af0cae1d419300',
algo: 'eth',
version: 0,
},
vegaSignature: {
value:
'abffba6ddf07b3a732214dd780c35c94f7f0aeb9c5e9ce7d1a3ee641926a6ac568a2ecf0160c859633d327712c7a1b1590db4245d40646559a1ab2cc44d6fa01',
algo: 'vega/ed25519',
version: 0,
},
},
blockHeight: '174534',
};
describe('NestedDataList', () => {
it('should display the parent as a button', () => {
const tree = render(<NestedDataList data={mockData} />);
const { getAllByRole } = tree;
expect(getAllByRole('button')[0]).toHaveTextContent('Validator Heartbeat');
});
it('should display the children when a row is clicked', async () => {
const tree = render(<NestedDataList data={mockData} />);
const user = userEvent.setup();
const { getAllByRole } = tree;
const parent = getAllByRole('listitem', { name: 'Validator Heartbeat' });
const nestedContainer = parent[0].querySelector('[aria-hidden]');
const expandBtn = parent[0].querySelector('button');
expect(nestedContainer).toHaveAttribute('aria-hidden', 'true');
await user.click(expandBtn as HTMLButtonElement);
await waitFor(() => nestedContainer);
expect(nestedContainer).toHaveAttribute('aria-hidden', 'false');
});
it('add border to the title of the parent', () => {
const tree = render(<NestedDataList data={mockData} />);
const { getAllByRole } = tree;
const parent = getAllByRole('listitem', { name: 'Validator Heartbeat' });
expect(parent[0]).toHaveClass('pl-4 border-l-4');
});
it('add a border the children with the same colour', () => {
const tree = render(<NestedDataList data={mockData} />);
const { getAllByRole } = tree;
const parent = getAllByRole('listitem', { name: 'Validator Heartbeat' });
expect(parent[0].querySelector('li')).toHaveClass('pl-4 border-l-4 pt-2');
});
it('should repeat the border colours in the correct order', () => {
const colourMockData = {
t0: {
t1: {
t2: {
t3: {
t4: {
t5: {
t6: {
t7: {
t8: {
hello: 'world',
},
},
},
},
},
},
},
},
},
};
const tree = render(<NestedDataList data={colourMockData} />);
const { getByTestId } = tree;
for (let i = 0; i < 8; i++) {
const item = getByTestId(`T${i}`);
const expected = BORDER_COLOURS.dark[i % 5];
expect(item.style.borderColor.toUpperCase()).toBe(expected);
}
});
it('should sort the data by values with children', () => {
const mockData = {
nonce: '5980890939790185837',
validatorHeartbeat: {
nodeId:
'e07d2cd299659590c16ec1cc1c69936ad747083c379ea6b6cfeaa6e22c8af0cb',
someArray: ['0', '1', '2'],
},
blockHeight: '174534',
};
const expected = ['nonce', 'blockHeight', 'validatorHeartbeat'];
const result = sortNestedDataByChildren(mockData);
expect(result).toStrictEqual(expected);
});
});

View File

@ -0,0 +1,178 @@
import React, { useCallback, useContext, useMemo, useState } from 'react';
import classNames from 'classnames';
import isObject from 'lodash/isObject';
import { ThemeContext } from '@vegaprotocol/react-helpers';
import { Icon } from '@vegaprotocol/ui-toolkit';
import { IconNames } from '@blueprintjs/icons';
import { VegaColours } from '@vegaprotocol/tailwindcss-config';
import isArray from 'lodash/isArray';
export type UnknownObject = Record<string, unknown>;
export type UnknownArray = unknown[];
interface NestedDataListProps {
data: UnknownObject | UnknownArray;
level?: number;
}
interface NestedDataListItemProps {
label: string;
value: unknown;
index: number;
}
export const BORDER_COLOURS = {
dark: [
VegaColours.pink.dark,
VegaColours.purple.dark,
VegaColours.green.dark,
VegaColours.blue.dark,
VegaColours.yellow.dark,
],
light: [
VegaColours.pink.DEFAULT,
VegaColours.purple.DEFAULT,
VegaColours.green.DEFAULT,
VegaColours.blue.DEFAULT,
VegaColours.yellow.DEFAULT,
],
};
const camelToTitle = (text: string) => {
const result = text.replace(/([A-Z])/g, ' $1');
return result.charAt(0).toUpperCase() + result.slice(1);
};
const getBorderColour = (index: number, theme: keyof typeof BORDER_COLOURS) => {
const colours = BORDER_COLOURS[theme];
const length = colours.length;
const modulo = index % length;
return colours[modulo];
};
const NestedDataListItem = ({
label,
value,
index,
}: NestedDataListItemProps) => {
const [isCollapsed, setCollapsed] = useState(true);
const toggleVisible = useCallback(
(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
event.stopPropagation();
setCollapsed(!isCollapsed);
},
[isCollapsed]
);
const hasChildren = isObject(value) && !!Object.keys(value).length;
const title = useMemo(() => camelToTitle(label), [label]);
const theme = useContext(ThemeContext);
const currentLevelBorder = useMemo(
() => getBorderColour(index, theme),
[index, theme]
);
const nextLevelBorder = useMemo(
() => getBorderColour(index + 1, theme),
[index, theme]
);
const isArr = isArray(value);
const listItemClasses = classNames('pl-4 border-l-4', {
'pt-10 last:pb-0': hasChildren,
'first:pt-0': hasChildren && !index,
'pt-2': !hasChildren && index,
});
const titleClasses = classNames({
'text-xl pl-4 border-l-4 font-alpha': hasChildren,
'text-base font-medium whitespace-nowrap': !hasChildren,
});
return (
<li
data-testid={title}
title={title}
className={listItemClasses}
style={{ borderColor: currentLevelBorder }}
>
<div className="flex flex-wrap">
<h4 className={titleClasses} style={{ borderColor: nextLevelBorder }}>
{hasChildren ? (
<button
className="flex items-center gap-2"
type="button"
onClick={toggleVisible}
>
<span className="">{title}</span>
<small className="px-1 text-sm rounded bg-vega-light-200 dark:bg-vega-dark-200">
{isArr ? 'array' : typeof value}
</small>
<Icon
name={
isCollapsed ? IconNames.CHEVRON_DOWN : IconNames.CHEVRON_UP
}
/>
</button>
) : (
<span className="mr-2">{title}:</span>
)}
</h4>
{!hasChildren && (
<code className="text-vega-light-400 mb-2 last:mb-0 dark:text-vega-dark-400 break-all">
{JSON.stringify(value, null, ' ')}
</code>
)}
</div>
{hasChildren && (
<div aria-hidden={isCollapsed} className={isCollapsed ? 'hidden' : ''}>
<NestedDataList
data={value as UnknownObject | UnknownArray}
level={index + 1}
/>
</div>
)}
</li>
);
};
export const sortNestedDataByChildren = (data: UnknownObject | UnknownArray) =>
Object.keys(data)
.filter((key) => key)
.sort((a, b) => {
const isArr = isArray(data);
const valA = isArr ? data[+a] : data[a];
const valB = isArr ? data[+b] : data[b];
const hasChildrenA = isObject(valA) && !!Object.keys(valA).length;
const hasChildrenB = isObject(valB) && !!Object.keys(valB).length;
if (hasChildrenA && !hasChildrenB) {
return 1;
}
if (!hasChildrenA && hasChildrenB) {
return -1;
}
return 0;
});
export const NestedDataList = ({ data, level = 0 }: NestedDataListProps) => {
const nestedItems = useMemo(() => {
const sortedData = sortNestedDataByChildren(data);
const isArr = isArray(data);
if (sortedData.length) {
return sortedData.map((key) => (
<NestedDataListItem
key={key}
label={key}
value={isArr ? data[Number(key)] : data[key]}
index={level}
/>
));
}
return null;
}, [data, level]);
return <ul className="list-none">{nestedItems}</ul>;
};

View File

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

View File

@ -0,0 +1,49 @@
import React from 'react';
import type { ReactNode } from 'react';
import { TruncateInline } from '../truncate/truncate';
import { CopyWithTooltip, Icon } from '@vegaprotocol/ui-toolkit';
interface PageHeaderProps {
title: string;
truncateStart?: number;
truncateEnd?: number;
copy?: boolean;
prefix?: string | ReactNode;
className?: string;
}
export const PageHeader = ({
prefix,
title,
truncateStart,
truncateEnd,
copy = false,
className,
}: PageHeaderProps) => {
const titleClasses = 'text-4xl xl:text-5xl uppercase font-alpha';
return (
<header className={className}>
<span className={`${titleClasses} block`}>{prefix}</span>
<div className="flex items-center gap-x-4">
<h2 className={titleClasses}>
{truncateStart && truncateEnd ? (
<TruncateInline
text={title}
startChars={truncateStart}
endChars={truncateEnd}
/>
) : (
title
)}
</h2>
{copy && (
<CopyWithTooltip data-testid="copy-to-clipboard" text={title}>
<button className="bg-zinc-100 dark:bg-zinc-900 rounded-sm py-2 px-3">
<Icon name="duplicate" className="" />
</button>
</CopyWithTooltip>
)}
</div>
</header>
);
};

View File

@ -9,16 +9,10 @@ import {
import React, { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { SubHeading } from '../../../components/sub-heading';
import {
CopyWithTooltip,
Icon,
SyntaxHighlighter,
AsyncRenderer,
} from '@vegaprotocol/ui-toolkit';
import { SyntaxHighlighter, AsyncRenderer } from '@vegaprotocol/ui-toolkit';
import { Panel } from '../../../components/panel';
import { InfoPanel } from '../../../components/info-panel';
import { toNonHex } from '../../../components/search/detect-search';
import { TruncateInline } from '../../../components/truncate/truncate';
import { DATA_SOURCES } from '../../../config';
import type {
PartyAssetsQuery,
@ -27,6 +21,7 @@ import type {
import type { TendermintSearchTransactionResponse } from '../tendermint-transaction-response';
import { useTxsData } from '../../../hooks/use-txs-data';
import { TxsInfiniteList } from '../../../components/txs';
import { PageHeader } from '../../../components/page-header';
const PARTY_ASSETS_QUERY = gql`
query PartyAssetsQuery($partyId: ID!) {
@ -91,19 +86,12 @@ const Party = () => {
);
const header = data?.party?.id ? (
<header className="flex items-center gap-x-4">
<TruncateInline
text={data.party.id}
startChars={visibleChars}
endChars={visibleChars}
className="text-4xl xl:text-5xl uppercase font-alpha"
/>
<CopyWithTooltip text={data.party.id}>
<button className="bg-zinc-100 dark:bg-zinc-900 rounded-sm py-2 px-3">
<Icon name="duplicate" className="" />
</button>
</CopyWithTooltip>
</header>
<PageHeader
title={data.party.id}
copy
truncateStart={visibleChars}
truncateEnd={visibleChars}
/>
) : (
<Panel>
<p>No party found for key {party}</p>

View File

@ -1,14 +1,16 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import { useFetch } from '@vegaprotocol/react-helpers';
import { DATA_SOURCES } from '../../../config';
import { RouteTitle } from '../../../components/route-title';
import { RenderFetched } from '../../../components/render-fetched';
import { TxContent } from './tx-content';
import { TxDetails } from './tx-details';
import { t } from '@vegaprotocol/react-helpers';
import type { BlockExplorerTransaction } from '../../../routes/types/block-explorer-response';
import { toNonHex } from '../../../components/search/detect-search';
import { PageHeader } from '../../../components/page-header';
import { Routes } from '../../../routes/route-names';
import { IconNames } from '@blueprintjs/icons';
import { Icon } from '@vegaprotocol/ui-toolkit';
const Tx = () => {
const { txHash } = useParams<{ txHash: string }>();
@ -22,7 +24,25 @@ const Tx = () => {
return (
<section>
<RouteTitle>{t('Transaction details')}</RouteTitle>
<Link
className="font-normal underline underline-offset-4 block mb-5"
to={`/${Routes.TX}`}
>
<Icon
className="text-vega-light-300 dark:text-vega-light-300"
name={IconNames.CHEVRON_LEFT}
/>
All Transactions
</Link>
<PageHeader
title={hash}
prefix="Transaction"
copy
truncateStart={5}
truncateEnd={9}
className="mb-5"
/>
<RenderFetched error={tTxError} loading={tTxLoading}>
<>
@ -32,10 +52,6 @@ const Tx = () => {
pubKey={data?.transaction.submitter}
/>
<h2 className="text-2xl uppercase mb-4">
{t('Transaction content')}
</h2>
<TxContent data={data?.transaction} />
</>
</RenderFetched>

View File

@ -1,13 +1,6 @@
import { t } from '@vegaprotocol/react-helpers';
import { StatusMessage } from '../../../components/status-message';
import { SyntaxHighlighter } from '@vegaprotocol/ui-toolkit';
import {
TableWithTbody,
TableCell,
TableHeader,
TableRow,
} from '../../../components/table';
import { TxOrderType } from '../../../components/txs';
import { NestedDataList } from '../../../components/nested-data-list';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
interface TxContentProps {
@ -23,21 +16,5 @@ export const TxContent = ({ data }: TxContentProps) => {
);
}
return (
<>
<TableWithTbody className="mb-12">
<TableRow modifier="bordered">
<TableHeader scope="row" className="w-[160px]">
{t('Type')}
</TableHeader>
<TableCell modifier="bordered">
<TxOrderType orderType={data.type} />
</TableCell>
</TableRow>
</TableWithTbody>
<h3 className="font-mono mb-8">{t('Decoded transaction content')}</h3>
<SyntaxHighlighter data={data.command} />
</>
);
return <NestedDataList data={data.command} />;
};

View File

@ -25,11 +25,6 @@ const renderComponent = (txData: BlockExplorerTransactionResult) => (
);
describe('Transaction details', () => {
it('Renders the tx hash', () => {
render(renderComponent(txData));
expect(screen.getByText(hash)).toBeInTheDocument();
});
it('Renders the pubKey', () => {
render(renderComponent(txData));
expect(screen.getByText(pubKey)).toBeInTheDocument();
@ -39,9 +34,4 @@ describe('Transaction details', () => {
render(renderComponent(txData));
expect(screen.getByText(height)).toBeInTheDocument();
});
it('Renders a copy button', () => {
render(renderComponent(txData));
expect(screen.getByTestId('copy-tx-to-clipboard')).toBeInTheDocument();
});
});

View File

@ -1,15 +1,9 @@
import { Routes } from '../../route-names';
import { CopyWithTooltip, Icon } from '@vegaprotocol/ui-toolkit';
import {
TableWithTbody,
TableCell,
TableHeader,
TableRow,
} from '../../../components/table';
import { t } from '@vegaprotocol/react-helpers';
import { HighlightedLink } from '../../../components/highlighted-link';
import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response';
import React from 'react';
import { TruncateInline } from '../../../components/truncate/truncate';
import { Link } from 'react-router-dom';
interface TxDetailsProps {
txData: BlockExplorerTransactionResult | undefined;
@ -24,40 +18,32 @@ export const TxDetails = ({ txData, pubKey, className }: TxDetailsProps) => {
return <>{t('Awaiting Block Explorer transaction details')}</>;
}
const truncatedSubmitter = (
<TruncateInline text={pubKey || ''} startChars={5} endChars={5} />
);
return (
<TableWithTbody className={className}>
<TableRow modifier="bordered">
<TableCell>{t('Hash')}</TableCell>
<TableCell modifier="bordered" data-testid="hash">
{txData.hash}
<CopyWithTooltip text={txData.hash}>
<button
title={t('Copy tx to clipboard')}
data-testid="copy-tx-to-clipboard"
className="underline"
>
<Icon name="duplicate" className="ml-2" />
</button>
</CopyWithTooltip>
</TableCell>
</TableRow>
<TableRow modifier="bordered">
<TableHeader scope="row" className="w-[160px]">
{t('Submitted by')}
</TableHeader>
<TableCell modifier="bordered" data-testid="submitted-by">
<HighlightedLink to={`/${Routes.PARTIES}/${pubKey}`} text={pubKey} />
</TableCell>
</TableRow>
<TableRow modifier="bordered">
<TableCell>{t('Block')}</TableCell>
<TableCell modifier="bordered" data-testid="block">
<HighlightedLink
to={`/${Routes.BLOCKS}/${txData.block}`}
text={txData.block}
/>
</TableCell>
</TableRow>
</TableWithTbody>
<section className="mb-10">
<h3 className="text-3xl xl:text-4xl uppercase font-alpha mb-4">
{txData.type} by{' '}
<Link
className="font-bold underline"
to={`/${Routes.PARTIES}/${pubKey}`}
>
{truncatedSubmitter}
</Link>
</h3>
<p className="text-xl xl:text-2xl uppercase font-alpha">
Block{' '}
<Link
className="font-bold underline"
to={`/${Routes.BLOCKS}/${txData.block}`}
>
{txData.block}
</Link>
{', '}
Index {txData.index}
</p>
</section>
);
};

View File

@ -1,6 +1,9 @@
const { join } = require('path');
const { createGlobPatternsForDependencies } = require('@nrwl/next/tailwind');
const theme = require('../../libs/tailwindcss-config/src/theme');
const {
VegaColours,
} = require('../../libs/tailwindcss-config/src/vega-colours');
const vegaCustomClasses = require('../../libs/tailwindcss-config/src/vega-custom-classes');
module.exports = {
@ -11,7 +14,12 @@ module.exports = {
],
darkMode: 'class',
theme: {
extend: theme,
extend: {
...theme,
colors: {
vega: VegaColours,
},
},
},
plugins: [vegaCustomClasses],
};

View File

@ -1,9 +1,11 @@
const theme = require('./theme');
const themelite = require('./theme-lite');
const vegaCustomClasses = require('./vega-custom-classes');
const { VegaColours } = require('./vega-colours');
module.exports = {
theme,
themelite,
plugins: [vegaCustomClasses],
VegaColours,
};

View File

@ -0,0 +1,38 @@
const VegaColours = {
yellow: {
DEFAULT: '#D7FB50',
dark: '#9BE106',
},
pink: {
DEFAULT: '#FF077F',
dark: '#CF0064',
},
green: {
DEFAULT: '#00F780',
dark: '#00D46E',
},
blue: {
DEFAULT: '#0075FF',
dark: '#0046CD',
},
purple: {
DEFAULT: '#8028FF',
dark: '#5D0CD2',
},
dark: {
100: '#161616',
200: '#404040',
300: '#8B8B8B',
400: '#C0C0C0',
},
light: {
100: '#F0F0F0',
200: '#D2D2D2',
300: '#A7A7A7',
400: '#626262',
},
};
module.exports = {
VegaColours,
};