fix(explorer): add party to consolidated search (#1588)

* fix(explorer): add party to consolidated search

* chore(explorer): add missing tests for detect-search.spec.ts
This commit is contained in:
Elmar 2022-10-03 13:20:00 +01:00 committed by GitHub
parent 09476eea38
commit 620bf1bab4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 430 additions and 71 deletions

View File

@ -14,7 +14,12 @@ jest.mock('../search', () => ({
const renderComponent = () => ( const renderComponent = () => (
<MemoryRouter> <MemoryRouter>
<Header /> <Header
theme="dark"
toggleTheme={jest.fn()}
menuOpen={false}
setMenuOpen={jest.fn()}
/>
</MemoryRouter> </MemoryRouter>
); );

View File

@ -0,0 +1,231 @@
import {
detectTypeByFetching,
detectTypeFromQuery,
getSearchType,
isBlock,
isHexadecimal,
isNetworkParty,
isNonHex,
SearchTypes,
toHex,
toNonHex,
} from './detect-search';
import { DATA_SOURCES } from '../../config';
global.fetch = jest.fn();
describe('Detect Search', () => {
it("should detect that it's a hexadecimal", () => {
const expected = true;
const testString =
'0x073ceaab59e5f2dd0561dec4883e7ee5bc7165cd4de34717a3ab8f2cbe3007f9';
const actual = isHexadecimal(testString);
expect(actual).toBe(expected);
});
it("should detect that it's not hexadecimal", () => {
const expected = true;
const testString =
'073ceaab59e5f2dd0561dec4883e7ee5bc7165cd4de34717a3ab8f2cbe3007f9';
const actual = isNonHex(testString);
expect(actual).toBe(expected);
});
it("should detect that it's a network party", () => {
const expected = true;
const testString = 'network';
const actual = isNetworkParty(testString);
expect(actual).toBe(expected);
});
it("should detect that it's a block", () => {
const expected = true;
const testString = '3188';
const actual = isBlock(testString);
expect(actual).toBe(expected);
});
it('should convert from non-hex to hex', () => {
const expected = '0x123';
const testString = '123';
const actual = toHex(testString);
expect(actual).toBe(expected);
});
it('should convert from hex to non-hex', () => {
const expected = '123';
const testString = '0x123';
const actual = toNonHex(testString);
expect(actual).toBe(expected);
});
it("should detect type client side from query if it's a hexadecimal", () => {
const expected = [SearchTypes.Party, SearchTypes.Transaction];
const testString =
'0x4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F';
const actual = detectTypeFromQuery(testString);
expect(actual).toStrictEqual(expected);
});
it("should detect type client side from query if it's a non hex", () => {
const expected = [SearchTypes.Party, SearchTypes.Transaction];
const testString =
'4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F';
const actual = detectTypeFromQuery(testString);
expect(actual).toStrictEqual(expected);
});
it("should detect type client side from query if it's a network party", () => {
const expected = [SearchTypes.Party];
const testString = 'network';
const actual = detectTypeFromQuery(testString);
expect(actual).toStrictEqual(expected);
});
it("should detect type client side from query if it's a block (number)", () => {
const expected = [SearchTypes.Block];
const testString = '23432';
const actual = detectTypeFromQuery(testString);
expect(actual).toStrictEqual(expected);
});
it("detectTypeByFetching should call fetch with hex query it's a transaction", async () => {
const query = 'abc';
const type = SearchTypes.Transaction;
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
result: {
tx: query,
},
}),
})
)
);
const result = await detectTypeByFetching(query, type);
expect(fetch).toHaveBeenCalledWith(
`${DATA_SOURCES.tendermintUrl}/tx?hash=0x${query}`
);
expect(result).toBe(type);
});
it("detectTypeByFetching should call fetch with non-hex query it's a party", async () => {
const query = 'abc';
const type = SearchTypes.Party;
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
result: {
txs: [query],
},
}),
})
)
);
const result = await detectTypeByFetching(query, type);
expect(fetch).toHaveBeenCalledWith(
`${DATA_SOURCES.tendermintUrl}/tx_search?query="tx.submitter='${query}'"`
);
expect(result).toBe(type);
});
it('detectTypeByFetching should return undefined if no matches', async () => {
const query = 'abc';
const type = SearchTypes.Party;
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok: false,
})
)
);
const result = await detectTypeByFetching(query, type);
expect(fetch).toHaveBeenCalledWith(
`${DATA_SOURCES.tendermintUrl}/tx_search?query="tx.submitter='${query}'"`
);
expect(result).toBe(undefined);
});
it('getSearchType should return party from fetch response', async () => {
const query =
'0x4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F';
const expected = SearchTypes.Party;
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
result: {
txs: [query],
},
}),
})
)
);
const result = await getSearchType(query);
expect(result).toBe(expected);
});
it('getSearchType should return party from transaction response', async () => {
const query =
'4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F';
const expected = SearchTypes.Transaction;
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
result: {
tx: query,
},
}),
})
)
);
const result = await getSearchType(query);
expect(result).toBe(expected);
});
it('getSearchType should return undefined from transaction response', async () => {
const query =
'0x4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F';
const expected = undefined;
// @ts-ignore issue related to polyfill
fetch.mockImplementation(
jest.fn(() =>
Promise.resolve({
ok: false,
})
)
);
const result = await getSearchType(query);
expect(result).toBe(expected);
});
it('getSearchType should return block if query is number', async () => {
const query = '123';
const expected = SearchTypes.Block;
const result = await getSearchType(query);
expect(result).toBe(expected);
});
it('getSearchType should return party if query is network', async () => {
const query = 'network';
const expected = SearchTypes.Party;
const result = await getSearchType(query);
expect(result).toBe(expected);
});
});

View File

@ -0,0 +1,104 @@
import { DATA_SOURCES } from '../../config';
export enum SearchTypes {
Transaction = 'transaction',
Party = 'party',
Block = 'block',
Order = 'order',
}
export const TX_LENGTH = 64;
export const isHexadecimal = (search: string) =>
search.startsWith('0x') && search.length === 2 + TX_LENGTH;
export const isNonHex = (search: string) =>
!search.startsWith('0x') && search.length === TX_LENGTH;
export const isBlock = (search: string) => !Number.isNaN(Number(search));
export const isNetworkParty = (search: string) => search === 'network';
export const toHex = (query: string) =>
isHexadecimal(query) ? query : `0x${query}`;
export const toNonHex = (query: string) =>
isNonHex(query) ? query : `${query.replace('0x', '')}`;
export const detectTypeFromQuery = (
query: string
): SearchTypes[] | undefined => {
const i = query.toLowerCase();
if (isHexadecimal(i) || isNonHex(i)) {
return [SearchTypes.Party, SearchTypes.Transaction];
} else if (isNetworkParty(i)) {
return [SearchTypes.Party];
} else if (isBlock(i)) {
return [SearchTypes.Block];
}
return undefined;
};
export const detectTypeByFetching = async (
query: string,
type: SearchTypes
): Promise<SearchTypes | undefined> => {
const TYPES = [SearchTypes.Party, SearchTypes.Transaction];
if (!TYPES.includes(type)) {
throw new Error('Search type provided not recognised');
}
if (type === SearchTypes.Transaction) {
const hash = toHex(query);
const request = await fetch(
`${DATA_SOURCES.tendermintUrl}/tx?hash=${hash}`
);
if (request?.ok) {
const body = await request.json();
if (body?.result?.tx) {
return SearchTypes.Transaction;
}
}
} else if (type === SearchTypes.Party) {
const party = toNonHex(query);
const request = await fetch(
`${DATA_SOURCES.tendermintUrl}/tx_search?query="tx.submitter='${party}'"`
);
if (request.ok) {
const body = await request.json();
if (body?.result?.txs?.length) {
return SearchTypes.Party;
}
}
}
return undefined;
};
export const getSearchType = async (
query: string
): Promise<SearchTypes | undefined> => {
const searchTypes = detectTypeFromQuery(query);
const hasResults = searchTypes?.length;
if (hasResults) {
if (hasResults > 1) {
const promises = searchTypes.map((type) =>
detectTypeByFetching(query, type)
);
const results = await Promise.all(promises);
return results.find((type) => type !== undefined);
}
return searchTypes[0];
}
return undefined;
};

View File

@ -2,14 +2,23 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { Search } from './search'; import { Search } from './search';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { Routes } from '../../routes/route-names'; import { Routes } from '../../routes/route-names';
import { SearchTypes, getSearchType } from './detect-search';
const mockedNavigate = jest.fn(); const mockedNavigate = jest.fn();
const mockGetSearchType = getSearchType as jest.MockedFunction<
typeof getSearchType
>;
jest.mock('react-router-dom', () => ({ jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), ...jest.requireActual('react-router-dom'),
useNavigate: () => mockedNavigate, useNavigate: () => mockedNavigate,
})); }));
jest.mock('./detect-search', () => ({
...jest.requireActual('./detect-search'),
getSearchType: jest.fn(),
}));
beforeEach(() => { beforeEach(() => {
mockedNavigate.mockClear(); mockedNavigate.mockClear();
}); });
@ -39,9 +48,10 @@ describe('Search', () => {
fireEvent.click(button); fireEvent.click(button);
expect(await screen.findByTestId('search-error')).toHaveTextContent( expect(await screen.findByTestId('search-error')).toHaveTextContent(
"Something doesn't look right" 'Transaction type is not recognised'
); );
}); });
it('should render error if no input is given', async () => { it('should render error if no input is given', async () => {
render(renderComponent()); render(renderComponent());
const { button } = getInputs(); const { button } = getInputs();
@ -49,47 +59,14 @@ describe('Search', () => {
fireEvent.click(button); fireEvent.click(button);
expect(await screen.findByTestId('search-error')).toHaveTextContent( expect(await screen.findByTestId('search-error')).toHaveTextContent(
'Search required' 'Search query required'
);
});
it('should render error if transaction is not hex', async () => {
render(renderComponent());
const { button, input } = getInputs();
fireEvent.change(input, {
target: {
value:
'0x123456789012345678901234567890123456789012345678901234567890123Q',
},
});
fireEvent.click(button);
expect(await screen.findByTestId('search-error')).toHaveTextContent(
'Transaction is not hexadecimal'
);
});
it('should render error if transaction is not hex and does not have leading 0x', async () => {
render(renderComponent());
const { button, input } = getInputs();
fireEvent.change(input, {
target: {
value:
'123456789012345678901234567890123456789012345678901234567890123Q',
},
});
fireEvent.click(button);
expect(await screen.findByTestId('search-error')).toHaveTextContent(
'Transaction is not hexadecimal'
); );
}); });
it('should redirect to transactions page', async () => { it('should redirect to transactions page', async () => {
render(renderComponent()); render(renderComponent());
const { button, input } = getInputs(); const { button, input } = getInputs();
mockGetSearchType.mockResolvedValue(SearchTypes.Transaction);
fireEvent.change(input, { fireEvent.change(input, {
target: { target: {
value: value:
@ -108,6 +85,7 @@ describe('Search', () => {
it('should redirect to transactions page without proceeding 0x', async () => { it('should redirect to transactions page without proceeding 0x', async () => {
render(renderComponent()); render(renderComponent());
const { button, input } = getInputs(); const { button, input } = getInputs();
mockGetSearchType.mockResolvedValue(SearchTypes.Transaction);
fireEvent.change(input, { fireEvent.change(input, {
target: { target: {
value: value:
@ -123,9 +101,48 @@ describe('Search', () => {
}); });
}); });
it('should redirect to parties page', async () => {
render(renderComponent());
const { button, input } = getInputs();
mockGetSearchType.mockResolvedValue(SearchTypes.Party);
fireEvent.change(input, {
target: {
value:
'0x1234567890123456789012345678901234567890123456789012345678901234',
},
});
fireEvent.click(button);
await waitFor(() => {
expect(mockedNavigate).toBeCalledWith(
`${Routes.PARTIES}/0x1234567890123456789012345678901234567890123456789012345678901234`
);
});
});
it('should redirect to parties page without proceeding 0x', async () => {
render(renderComponent());
const { button, input } = getInputs();
mockGetSearchType.mockResolvedValue(SearchTypes.Party);
fireEvent.change(input, {
target: {
value:
'1234567890123456789012345678901234567890123456789012345678901234',
},
});
fireEvent.click(button);
await waitFor(() => {
expect(mockedNavigate).toBeCalledWith(
`${Routes.PARTIES}/0x1234567890123456789012345678901234567890123456789012345678901234`
);
});
});
it('should redirect to blocks page if passed a number', async () => { it('should redirect to blocks page if passed a number', async () => {
render(renderComponent()); render(renderComponent());
const { button, input } = getInputs(); const { button, input } = getInputs();
mockGetSearchType.mockResolvedValue(SearchTypes.Block);
fireEvent.change(input, { fireEvent.change(input, {
target: { target: {
value: '123', value: '123',

View File

@ -1,55 +1,57 @@
import React, { useCallback, useState } from 'react';
import { t } from '@vegaprotocol/react-helpers'; import { t } from '@vegaprotocol/react-helpers';
import { Input, InputError, Button } from '@vegaprotocol/ui-toolkit'; import { Button, Input, InputError } from '@vegaprotocol/ui-toolkit';
import React from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getSearchType, SearchTypes, toHex } from './detect-search';
import { Routes } from '../../routes/route-names'; import { Routes } from '../../routes/route-names';
const TX_LENGTH = 64;
interface FormFields { interface FormFields {
search: string; search: string;
} }
const isPrependedTransaction = (search: string) =>
search.startsWith('0x') && search.length === 2 + TX_LENGTH;
const isTransaction = (search: string) =>
!search.startsWith('0x') && search.length === TX_LENGTH;
const isBlock = (search: string) => !Number.isNaN(Number(search));
export const Search = () => { export const Search = () => {
const { register, handleSubmit } = useForm<FormFields>(); const { register, handleSubmit } = useForm<FormFields>();
const navigate = useNavigate(); const navigate = useNavigate();
const [error, setError] = React.useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
const onSubmit = React.useCallback(
(fields: FormFields) => { const onSubmit = useCallback(
async (fields: FormFields) => {
setError(null); setError(null);
const search = fields.search; const query = fields.search;
if (!search) {
setError(new Error(t('Search required'))); if (!query) {
} else if (isPrependedTransaction(search)) { setError(new Error(t('Search query required')));
if (Number.isNaN(Number(search))) {
setError(new Error(t('Transaction is not hexadecimal')));
} else {
navigate(`${Routes.TX}/${search}`);
}
} else if (isTransaction(search)) {
if (Number.isNaN(Number(`0x${search}`))) {
setError(new Error(t('Transaction is not hexadecimal')));
} else {
navigate(`${Routes.TX}/0x${search}`);
}
} else if (isBlock(search)) {
navigate(`${Routes.BLOCKS}/${Number(search)}`);
} else { } else {
setError(new Error(t("Something doesn't look right"))); const result = await getSearchType(query);
const urlAsHex = toHex(query);
const unrecognisedError = new Error(
t('Transaction type is not recognised')
);
if (result) {
switch (result) {
case SearchTypes.Party:
navigate(`${Routes.PARTIES}/${urlAsHex}`);
break;
case SearchTypes.Transaction:
navigate(`${Routes.TX}/${urlAsHex}`);
break;
case SearchTypes.Block:
navigate(`${Routes.BLOCKS}/${Number(query)}`);
break;
default:
setError(unrecognisedError);
}
}
setError(unrecognisedError);
} }
}, },
[navigate] [navigate]
); );
return ( return (
<form <form
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
@ -66,7 +68,7 @@ export const Search = () => {
className="text-white" className="text-white"
hasError={Boolean(error?.message)} hasError={Boolean(error?.message)}
type="text" type="text"
placeholder={t('Enter block number or transaction hash')} placeholder={t('Enter block number, party id or transaction hash')}
/> />
{error?.message && ( {error?.message && (
<div className="absolute top-[100%] flex-1 w-full"> <div className="absolute top-[100%] flex-1 w-full">