From e97c6d832af0c5c39b52ec8a5a0ab420e837017b Mon Sep 17 00:00:00 2001 From: Vasyl Ivanchuk Date: Thu, 9 Nov 2023 14:38:10 +0200 Subject: [PATCH] feat: add search route (#84) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # 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 - [X] PR title corresponds to the body of PR (we generate changelog entries from PRs). - [X] Tests for the changes have been added / updated. --- packages/app/src/composables/useSearch.ts | 81 ++++++------ packages/app/src/router/routes.ts | 21 +++- .../app/tests/composables/useSearch.spec.ts | 118 ++++++++++++------ packages/app/tests/views/AddressView.spec.ts | 10 +- packages/app/tests/views/BatchView.spec.ts | 10 +- packages/app/tests/views/BatchesView.spec.ts | 2 +- packages/app/tests/views/BlockView.spec.ts | 10 +- packages/app/tests/views/BlocksView.spec.ts | 2 +- .../views/ContractVerificationView.spec.ts | 2 +- packages/app/tests/views/DebuggerView.spec.ts | 2 +- packages/app/tests/views/HomeView.spec.ts | 2 +- .../app/tests/views/TransactionView.spec.ts | 10 +- .../app/tests/views/TransactionsView.spec.ts | 2 +- 13 files changed, 186 insertions(+), 86 deletions(-) diff --git a/packages/app/src/composables/useSearch.ts b/packages/app/src/composables/useSearch.ts index 3dff3785c9..97fd55c685 100644 --- a/packages/app/src/composables/useSearch.ts +++ b/packages/app/src/composables/useSearch.ts @@ -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, }; diff --git a/packages/app/src/router/routes.ts b/packages/app/src/router/routes.ts index 7f2e35ce26..aef4b313aa 100644 --- a/packages/app/src/router/routes.ts +++ b/packages/app/src/router/routes.ts @@ -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 [ { @@ -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", @@ -102,4 +121,4 @@ export default [ title: "document.home", }, }, -]; +] as RouteRecordRaw[]; diff --git a/packages/app/tests/composables/useSearch.spec.ts b/packages/app/tests/composables/useSearch.spec.ts index 03b753614f..c00fce0a48 100644 --- a/packages/app/tests/composables/useSearch.spec.ts +++ b/packages/app/tests/composables/useSearch.spec.ts @@ -4,6 +4,8 @@ import { $fetch } from "ohmyfetch"; import useSearch from "@/composables/useSearch"; +import * as validators from "@/utils/validators"; + const router = { push: vi.fn(), }; @@ -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" }, + }); }); }); }); diff --git a/packages/app/tests/views/AddressView.spec.ts b/packages/app/tests/views/AddressView.spec.ts index 89ba0a6752..50bb7b5496 100644 --- a/packages/app/tests/views/AddressView.spec.ts +++ b/packages/app/tests/views/AddressView.spec.ts @@ -31,6 +31,14 @@ const router = { }, }; +vi.mock("@/composables/useSearch", () => { + return { + default: () => ({ + getSearchRoute: () => null, + }), + }; +}); + vi.mock("vue-router", () => ({ useRouter: () => router, useRoute: () => ({ @@ -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'", () => { diff --git a/packages/app/tests/views/BatchView.spec.ts b/packages/app/tests/views/BatchView.spec.ts index 994d8e1be7..d94fff9c21 100644 --- a/packages/app/tests/views/BatchView.spec.ts +++ b/packages/app/tests/views/BatchView.spec.ts @@ -19,6 +19,14 @@ const router = { }, }; +vi.mock("@/composables/useSearch", () => { + return { + default: () => ({ + getSearchRoute: () => null, + }), + }; +}); + vi.mock("vue-router", () => ({ useRouter: () => router, useRoute: () => vi.fn(), @@ -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 () => { diff --git a/packages/app/tests/views/BatchesView.spec.ts b/packages/app/tests/views/BatchesView.spec.ts index dd913b9ffe..4b78329969 100644 --- a/packages/app/tests/views/BatchesView.spec.ts +++ b/packages/app/tests/views/BatchesView.spec.ts @@ -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 () => { diff --git a/packages/app/tests/views/BlockView.spec.ts b/packages/app/tests/views/BlockView.spec.ts index 3d1307bc0f..6e8d60ca7a 100644 --- a/packages/app/tests/views/BlockView.spec.ts +++ b/packages/app/tests/views/BlockView.spec.ts @@ -20,6 +20,14 @@ const router = { }, }; +vi.mock("@/composables/useSearch", () => { + return { + default: () => ({ + getSearchRoute: () => null, + }), + }; +}); + vi.mock("vue-router", () => ({ useRouter: () => router, useRoute: () => vi.fn(), @@ -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 () => { diff --git a/packages/app/tests/views/BlocksView.spec.ts b/packages/app/tests/views/BlocksView.spec.ts index b3fa57b41f..66159caf3e 100644 --- a/packages/app/tests/views/BlocksView.spec.ts +++ b/packages/app/tests/views/BlocksView.spec.ts @@ -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 () => { diff --git a/packages/app/tests/views/ContractVerificationView.spec.ts b/packages/app/tests/views/ContractVerificationView.spec.ts index 18e1e72375..8f96244a2b 100644 --- a/packages/app/tests/views/ContractVerificationView.spec.ts +++ b/packages/app/tests/views/ContractVerificationView.spec.ts @@ -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" ); }); diff --git a/packages/app/tests/views/DebuggerView.spec.ts b/packages/app/tests/views/DebuggerView.spec.ts index 7cd7940e97..16e61882b7 100644 --- a/packages/app/tests/views/DebuggerView.spec.ts +++ b/packages/app/tests/views/DebuggerView.spec.ts @@ -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", () => { diff --git a/packages/app/tests/views/HomeView.spec.ts b/packages/app/tests/views/HomeView.spec.ts index 83a1fd7afd..af64f00644 100644 --- a/packages/app/tests/views/HomeView.spec.ts +++ b/packages/app/tests/views/HomeView.spec.ts @@ -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" ); }); diff --git a/packages/app/tests/views/TransactionView.spec.ts b/packages/app/tests/views/TransactionView.spec.ts index 2240f68c10..823114577c 100644 --- a/packages/app/tests/views/TransactionView.spec.ts +++ b/packages/app/tests/views/TransactionView.spec.ts @@ -22,6 +22,14 @@ const router = { }, }; +vi.mock("@/composables/useSearch", () => { + return { + default: () => ({ + getSearchRoute: () => null, + }), + }; +}); + vi.mock("vue-router", () => ({ useRouter: () => router, useRoute: () => ({ @@ -48,7 +56,7 @@ describe("TransactionView:", () => { }); it("has correct title", async () => { - expect(i18n.global.t(routes.find((e) => e.name === "transaction")?.meta.title as string)).toBe("Transaction"); + expect(i18n.global.t(routes.find((e) => e.name === "transaction")?.meta?.title as string)).toBe("Transaction"); }); it("route is replaced with not found view on request 404 error", async () => { diff --git a/packages/app/tests/views/TransactionsView.spec.ts b/packages/app/tests/views/TransactionsView.spec.ts index 3fb8da8386..98c0d5850d 100644 --- a/packages/app/tests/views/TransactionsView.spec.ts +++ b/packages/app/tests/views/TransactionsView.spec.ts @@ -59,7 +59,7 @@ describe("TransactionsView:", () => { }); it("has correct title", async () => { - expect(i18n.global.t(routes.find((e) => e.name === "transactions")?.meta.title as string)).toBe("Transactions"); + expect(i18n.global.t(routes.find((e) => e.name === "transactions")?.meta?.title as string)).toBe("Transactions"); }); it("renders correctly", async () => {