Skip to content

Commit

Permalink
feat: add search route (#84)
Browse files Browse the repository at this point in the history
# What ❔

App search route which redirects the request to tx, batch or address
route depending on the param. Examples:
```
/search?q=123 -> /batch/123

/search?q=0x7e341fa7fa5368ece4814825fd181697404b5d243172db071bf5562c02976ad5 -> /tx/0x7e341fa7fa5368ece4814825fd181697404b5d243172db071bf5562c02976ad5

/search?q=0x043927b4e501835556057472A5636e99fC247e9A -> /address/0x043927b4e501835556057472A5636e99fC247e9A
```

## Why ❔

Unified search URL is requested by developers who work on integrations
with zkSync.

## Checklist

<!-- Check your PR fulfills the following items. -->
<!-- For draft PRs check the boxes as you complete them. -->

- [X] PR title corresponds to the body of PR (we generate changelog
entries from PRs).
- [X] Tests for the changes have been added / updated.
  • Loading branch information
vasyl-ivanchuk authored Nov 9, 2023
1 parent 08518d1 commit e97c6d8
Show file tree
Hide file tree
Showing 13 changed files with 186 additions and 86 deletions.
81 changes: 42 additions & 39 deletions packages/app/src/composables/useSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,54 +12,57 @@ export default (context = useContext()) => {
const isRequestPending = ref(false);
const isRequestFailed = ref(false);

const search = async (param: string) => {
const endpoints = [
{
routeParam: { address: param },
apiRoute: "address",
isValid: isAddress(param),
routeName: "address",
},
{
routeParam: { id: param },
apiRoute: "batches",
isValid: isBlockNumber(param),
routeName: "batch",
},
{
routeParam: { hash: param },
apiRoute: "transactions",
isValid: isTransactionHash(param),
routeName: "transaction",
},
];
isRequestPending.value = true;
const getSearchRoute = (param: string) => {
try {
for (const item of endpoints) {
try {
if (!item.isValid) {
continue;
}
await $fetch(`${context.currentNetwork.value.apiUrl}/${item.apiRoute}/${param}`);
const searchRoutes = [
{
routeParam: { address: param },
apiRoute: "address",
isValid: () => isAddress(param),
routeName: "address",
},
{
routeParam: { id: param },
apiRoute: "batches",
isValid: () => isBlockNumber(param),
routeName: "batch",
},
{
routeParam: { hash: param },
apiRoute: "transactions",
isValid: () => isTransactionHash(param),
routeName: "transaction",
},
];

await router.push({ name: item.routeName, params: item.routeParam });
return;
} catch (error) {
if (!(error instanceof FetchError) || (error instanceof FetchError && error.response?.status !== 404)) {
throw error;
}
return searchRoutes.find((searchRoute) => searchRoute.isValid());
} catch {
return null;
}
};

const search = async (param: string) => {
isRequestPending.value = true;
const searchRoute = getSearchRoute(param);
if (searchRoute) {
try {
await $fetch(`${context.currentNetwork.value.apiUrl}/${searchRoute.apiRoute}/${param}`);
await router.push({ name: searchRoute.routeName, params: searchRoute.routeParam });
return;
} catch (error) {
if (!(error instanceof FetchError) || (error instanceof FetchError && error.response?.status !== 404)) {
isRequestFailed.value = true;
}
} finally {
isRequestPending.value = false;
}
await router.push({ name: "not-found" });
} catch (error) {
isRequestFailed.value = true;
} finally {
isRequestPending.value = false;
}
await router.push({ name: "not-found" });
};

return {
search,
getSearchRoute,
isRequestPending,
isRequestFailed,
};
Expand Down
21 changes: 20 additions & 1 deletion packages/app/src/router/routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import useSearch from "@/composables/useSearch";

import type { RouteLocation, RouteRecordRaw } from "vue-router";

import HomeView from "@/views/HomeView.vue";
const { getSearchRoute } = useSearch();

export default [
{
Expand Down Expand Up @@ -94,6 +99,20 @@ export default [
title: "batches.batch",
},
},
{
path: "/search",
name: "search",
redirect: (to: RouteLocation) => {
const searchQueryParam = to.query?.q instanceof Array ? to.query.q.at(-1) : to.query?.q;
if (searchQueryParam) {
const searchRoute = getSearchRoute(searchQueryParam);
if (searchRoute) {
return { name: searchRoute.routeName, params: searchRoute.routeParam, query: null };
}
}
return { name: "not-found", query: null };
},
},
{
path: "/:pathMatch(.*)*",
name: "not-found",
Expand All @@ -102,4 +121,4 @@ export default [
title: "document.home",
},
},
];
] as RouteRecordRaw[];
118 changes: 82 additions & 36 deletions packages/app/tests/composables/useSearch.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { $fetch } from "ohmyfetch";

import useSearch from "@/composables/useSearch";

import * as validators from "@/utils/validators";

const router = {
push: vi.fn(),
};
Expand All @@ -24,50 +26,94 @@ describe("UseSearch:", () => {
expect(result.isRequestPending).toBeDefined();
expect(result.isRequestFailed).toBeDefined();
expect(result.search).toBeDefined();
expect(result.getSearchRoute).toBeDefined();
});

it("sets routerName and param to router push function when param is address", async () => {
const result = useSearch();
await result.search("0xc2675ae7f35b7d85ed1e828ccf6d0376b01adea3");
expect(router.push).toHaveBeenCalledWith({
name: "address",
params: { address: "0xc2675ae7f35b7d85ed1e828ccf6d0376b01adea3" },
describe("getSearchRoute", () => {
it("returns search route for the address param", () => {
const { getSearchRoute } = useSearch();
const searchRoute = getSearchRoute("0xc2675ae7f35b7d85ed1e828ccf6d0376b01adea3");
expect(searchRoute!.apiRoute).toBe("address");
expect(searchRoute!.routeName).toBe("address");
expect(searchRoute!.routeParam).toEqual({
address: "0xc2675ae7f35b7d85ed1e828ccf6d0376b01adea3",
});
});
});
it("sets routerName and param to router push function when param is contract address", async () => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const mock = ($fetch as any).mockResolvedValue({ accountType: "contract" });
const result = useSearch();
await result.search("0xca063a2ab07491ee991dcecb456d1265f842b568");

expect(router.push).toHaveBeenCalledWith({
name: "address",
params: { address: "0xca063a2ab07491ee991dcecb456d1265f842b568" },
it("returns search route for the transaction hash param", () => {
const { getSearchRoute } = useSearch();
const searchRoute = getSearchRoute("0xce8225eb5c843ceb1729447c9415bff9bd0fb75ff4263b309a79b03f1c0d50b0");
expect(searchRoute!.apiRoute).toBe("transactions");
expect(searchRoute!.routeName).toBe("transaction");
expect(searchRoute!.routeParam).toEqual({
hash: "0xce8225eb5c843ceb1729447c9415bff9bd0fb75ff4263b309a79b03f1c0d50b0",
});
});
mock.mockRestore();
});
it("sets routerName and param to router push function when param is batch id", async () => {
const result = useSearch();
await result.search("4123");
expect(router.push).toHaveBeenCalledWith({
name: "batch",
params: { id: "4123" },

it("returns search route for batch number param", () => {
const { getSearchRoute } = useSearch();
const searchRoute = getSearchRoute("123");
expect(searchRoute!.apiRoute).toBe("batches");
expect(searchRoute!.routeName).toBe("batch");
expect(searchRoute!.routeParam).toEqual({
id: "123",
});
});
});
it("sets routerName and param to router push function when param is transaction hash", async () => {
const result = useSearch();
await result.search("0xce8225eb5c843ceb1729447c9415bff9bd0fb75ff4263b309a79b03f1c0d50b0");
expect(router.push).toHaveBeenCalledWith({
name: "transaction",
params: { hash: "0xce8225eb5c843ceb1729447c9415bff9bd0fb75ff4263b309a79b03f1c0d50b0" },

it("returns null in case of an error", () => {
vi.spyOn(validators, "isAddress").mockImplementationOnce(() => {
throw new Error("invalid address");
});
const { getSearchRoute } = useSearch();
const searchRoute = getSearchRoute("123");
expect(searchRoute).toBeNull();
});
});
it("sets routerName and param to router push function when param is transaction hash", async () => {
const result = useSearch();
await result.search("6547236587245bjhkbf54");
expect(router.push).toHaveBeenCalledWith({
name: "transaction",
params: { hash: "0xce8225eb5c843ceb1729447c9415bff9bd0fb75ff4263b309a79b03f1c0d50b0" },

describe("search", () => {
it("sets routerName and param to router push function when param is address", async () => {
const result = useSearch();
await result.search("0xc2675ae7f35b7d85ed1e828ccf6d0376b01adea3");
expect(router.push).toHaveBeenCalledWith({
name: "address",
params: { address: "0xc2675ae7f35b7d85ed1e828ccf6d0376b01adea3" },
});
});
it("sets routerName and param to router push function when param is contract address", async () => {
/* eslint-disable @typescript-eslint/no-explicit-any */
const mock = ($fetch as any).mockResolvedValue({ accountType: "contract" });
const result = useSearch();
await result.search("0xca063a2ab07491ee991dcecb456d1265f842b568");

expect(router.push).toHaveBeenCalledWith({
name: "address",
params: { address: "0xca063a2ab07491ee991dcecb456d1265f842b568" },
});
mock.mockRestore();
});
it("sets routerName and param to router push function when param is batch id", async () => {
const result = useSearch();
await result.search("4123");
expect(router.push).toHaveBeenCalledWith({
name: "batch",
params: { id: "4123" },
});
});
it("sets routerName and param to router push function when param is transaction hash", async () => {
const result = useSearch();
await result.search("0xce8225eb5c843ceb1729447c9415bff9bd0fb75ff4263b309a79b03f1c0d50b0");
expect(router.push).toHaveBeenCalledWith({
name: "transaction",
params: { hash: "0xce8225eb5c843ceb1729447c9415bff9bd0fb75ff4263b309a79b03f1c0d50b0" },
});
});
it("sets routerName and param to router push function when param is transaction hash", async () => {
const result = useSearch();
await result.search("6547236587245bjhkbf54");
expect(router.push).toHaveBeenCalledWith({
name: "transaction",
params: { hash: "0xce8225eb5c843ceb1729447c9415bff9bd0fb75ff4263b309a79b03f1c0d50b0" },
});
});
});
});
10 changes: 9 additions & 1 deletion packages/app/tests/views/AddressView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ const router = {
},
};

vi.mock("@/composables/useSearch", () => {
return {
default: () => ({
getSearchRoute: () => null,
}),
};
});

vi.mock("vue-router", () => ({
useRouter: () => router,
useRoute: () => ({
Expand Down Expand Up @@ -69,7 +77,7 @@ describe("AddressView: ", () => {
});

it("has correct title", async () => {
expect(i18n.global.t(routes.find((e) => e.name === "address")?.meta.title as string)).toBe("Address");
expect(i18n.global.t(routes.find((e) => e.name === "address")?.meta?.title as string)).toBe("Address");
});

it("renders Account component if address type 'account'", () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/app/tests/views/BatchView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ const router = {
},
};

vi.mock("@/composables/useSearch", () => {
return {
default: () => ({
getSearchRoute: () => null,
}),
};
});

vi.mock("vue-router", () => ({
useRouter: () => router,
useRoute: () => vi.fn(),
Expand All @@ -43,7 +51,7 @@ describe("BatchView:", () => {
});

it("has correct title", async () => {
expect(i18n.global.t(routes.find((e) => e.name === "batch")?.meta.title as string)).toBe("Batch");
expect(i18n.global.t(routes.find((e) => e.name === "batch")?.meta?.title as string)).toBe("Batch");
});

it("route is replaced with not found view on request 404 error", async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/tests/views/BatchesView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe("BatchesView:", () => {
data: ref(getMockCollection(10)),
total: ref(100),
});
expect(i18n.global.t(routes.find((e) => e.name === "batches")?.meta.title as string)).toBe("Batches");
expect(i18n.global.t(routes.find((e) => e.name === "batches")?.meta?.title as string)).toBe("Batches");
});

it("renders correctly", async () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/app/tests/views/BlockView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ const router = {
},
};

vi.mock("@/composables/useSearch", () => {
return {
default: () => ({
getSearchRoute: () => null,
}),
};
});

vi.mock("vue-router", () => ({
useRouter: () => router,
useRoute: () => vi.fn(),
Expand All @@ -44,7 +52,7 @@ describe("BlockView:", () => {
});

it("has correct title", async () => {
expect(i18n.global.t(routes.find((e) => e.name === "block")?.meta.title as string)).toBe("Block");
expect(i18n.global.t(routes.find((e) => e.name === "block")?.meta?.title as string)).toBe("Block");
});

it("route is replaced with not found view on request 404 error", async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/tests/views/BlocksView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ describe("BlocksView:", () => {
data: ref(getMockCollection(10)),
total: ref(100),
});
expect(i18n.global.t(routes.find((e) => e.name === "blocks")?.meta.title as string)).toBe("Blocks");
expect(i18n.global.t(routes.find((e) => e.name === "blocks")?.meta?.title as string)).toBe("Blocks");
});

it("renders correctly", async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/tests/views/ContractVerificationView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ describe("ContractVerificationView:", () => {
});

it("has correct title", async () => {
expect(i18n.global.t(routes.find((e) => e.name === "contract-verification")?.meta.title as string)).toBe(
expect(i18n.global.t(routes.find((e) => e.name === "contract-verification")?.meta?.title as string)).toBe(
"Smart Contract Verification"
);
});
Expand Down
2 changes: 1 addition & 1 deletion packages/app/tests/views/DebuggerView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ describe("DebuggerView:", () => {
useRoute: () => vi.fn(),
}));
it("has correct title", async () => {
expect(i18n.global.t(routes.find((e) => e.name === "debugger")?.meta.title as string)).toBe("zkEVM Debugger");
expect(i18n.global.t(routes.find((e) => e.name === "debugger")?.meta?.title as string)).toBe("zkEVM Debugger");
});

it("renders empty state when trace is empty", () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/app/tests/views/HomeView.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ describe("HomeView:", () => {
};

it("has correct title", async () => {
expect(i18n.global.t(routes.find((e) => e.name === "home")?.meta.title as string)).toBe(
expect(i18n.global.t(routes.find((e) => e.name === "home")?.meta?.title as string)).toBe(
"Transactions, Blocks, Contracts and much more"
);
});
Expand Down
Loading

0 comments on commit e97c6d8

Please sign in to comment.